From c1b34ecb9c895eb5e3601ceb28d11d2d299b5965 Mon Sep 17 00:00:00 2001 From: Roman Malenko Date: Fri, 7 Aug 2026 11:44:34 +0300 Subject: [PATCH 01/31] fix(routes): block arbitrary file reads through the frontend handler --- application/backend/app/routes/routes.go | 12 ++-- .../backend/app/routes/security_test.go | 58 +++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 application/backend/app/routes/security_test.go diff --git a/application/backend/app/routes/routes.go b/application/backend/app/routes/routes.go index dcce55a..4fe00ad 100644 --- a/application/backend/app/routes/routes.go +++ b/application/backend/app/routes/routes.go @@ -86,18 +86,18 @@ func verifyRequest(w *http.ResponseWriter, req *http.Request) bool { } func (h *RouteController)Frontend(w http.ResponseWriter, req *http.Request) { - requestedPath := strings.ReplaceAll(req.URL.String(), os.Getenv("ONLOGS_PATH_PREFIX"), "") + // The root must be a constant. http.Dir sanitises the name it is given but + // never its own root, so anything caller-controlled in the root (the query + // string included) is an arbitrary file read. + dir := http.Dir("dist") - dirPath, fileName := filepath.Split(requestedPath) - if fileName == "" { + fileName := strings.TrimPrefix(strings.TrimPrefix(req.URL.Path, os.Getenv("ONLOGS_PATH_PREFIX")), "/") + if fileName == "" || strings.HasSuffix(fileName, "/") { fileName = "index.html" } - fileName = strings.Split(fileName, "?")[0] - dir := http.Dir("dist" + dirPath) file, err := dir.Open(fileName) if err != nil { - dir = http.Dir("dist") file, err = dir.Open("index.html") fileName = "index.html" } diff --git a/application/backend/app/routes/security_test.go b/application/backend/app/routes/security_test.go new file mode 100644 index 0000000..8a8c0e9 --- /dev/null +++ b/application/backend/app/routes/security_test.go @@ -0,0 +1,58 @@ +package routes + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +// B1 — Frontend built the http.Dir ROOT from the raw URL including the query +// string, so `?x=/../../` escaped the dist directory entirely and served +// any file on the filesystem to an unauthenticated caller. +func TestFrontendDoesNotServeFilesOutsideDist(t *testing.T) { + ctrl := initTestConfig() + os.MkdirAll("dist", 0o700) + os.WriteFile("dist/index.html", []byte("text"), 0o600) + + os.MkdirAll("leveldb_probe", 0o700) + os.WriteFile("leveldb_probe/JWT_secret", []byte("REAL-ONLOGS-JWT-SECRET"), 0o600) + t.Cleanup(func() { os.RemoveAll("leveldb_probe") }) + + for _, target := range []string{ + "/?x=/../../leveldb_probe/JWT_secret", + "/index.html?x=/../../leveldb_probe/JWT_secret", + "/?/../../leveldb_probe/JWT_secret", + } { + req, _ := http.NewRequest("GET", target, nil) + rr := httptest.NewRecorder() + http.HandlerFunc(ctrl.Frontend).ServeHTTP(rr, req) + body, _ := io.ReadAll(rr.Result().Body) + if strings.Contains(string(body), "REAL-ONLOGS-JWT-SECRET") { + t.Fatalf("arbitrary file read through %q: %s", target, string(body)) + } + } +} + +// B14 — the prefix was stripped with ReplaceAll at every position, so any asset +// path that repeats the prefix was rewritten into a path that does not exist and +// silently fell back to index.html. +func TestFrontendStripsPathPrefixOnlyAtTheFront(t *testing.T) { + ctrl := initTestConfig() + t.Setenv("ONLOGS_PATH_PREFIX", "/logs") + + os.MkdirAll("dist/assets", 0o700) + os.WriteFile("dist/index.html", []byte("text"), 0o600) + os.WriteFile("dist/assets/logs-panel.js", []byte("PANEL_ASSET"), 0o600) + t.Cleanup(func() { os.RemoveAll("dist/assets") }) + + req, _ := http.NewRequest("GET", "/logs/assets/logs-panel.js", nil) + rr := httptest.NewRecorder() + http.HandlerFunc(ctrl.Frontend).ServeHTTP(rr, req) + body, _ := io.ReadAll(rr.Result().Body) + if string(body) != "PANEL_ASSET" { + t.Fatalf("expected the asset, got %q", string(body)) + } +} From 9bea27ea3d188b199dda557e64e9f417d6a7b5cb Mon Sep 17 00:00:00 2001 From: Roman Malenko Date: Fri, 7 Aug 2026 22:03:49 +0300 Subject: [PATCH 02/31] fix(auth)!: hash stored passwords and require an admin password --- README.md | 4 +- .../backend/app/containerdb/containerdb.go | 54 ++++- .../backend/app/containerdb/delete_test.go | 46 ++++ .../backend/app/containerdb/limit_test.go | 75 ++++++ application/backend/app/daemon/daemon.go | 6 + application/backend/app/db/db.go | 109 +++++++-- application/backend/app/db/reap_test.go | 56 +++++ application/backend/app/db/token_test.go | 101 ++++++++ application/backend/app/routes/loginlimit.go | 100 ++++++++ application/backend/app/routes/routes.go | 172 ++++++++++---- application/backend/app/routes/routes_test.go | 8 + .../backend/app/routes/security_test.go | 218 +++++++++++++++++- .../backend/app/statistics/statistics.go | 12 +- application/backend/app/userdb/password.go | 58 +++++ .../backend/app/userdb/password_test.go | 85 +++++++ application/backend/app/userdb/userdb.go | 27 ++- application/backend/app/userdb/userdb_test.go | 9 +- application/backend/app/util/inituser_test.go | 30 +++ application/backend/app/util/jwt_test.go | 105 +++++++++ application/backend/app/util/safename_test.go | 86 +++++++ application/backend/app/util/util.go | 70 ++++-- application/backend/app/util/util_test.go | 10 +- application/backend/main.go | 70 +++++- application/backend/main_test.go | 82 +++++++ application/frontend/package-lock.json | 6 - application/frontend/package.json | 6 +- application/frontend/scripts/run-tests.mjs | 20 ++ .../src/lib/LogsString/LogsString.svelte | 2 +- application/frontend/src/utils/functions.js | 28 +-- .../frontend/src/utils/functions.test.mjs | 53 +++++ 30 files changed, 1546 insertions(+), 162 deletions(-) create mode 100644 application/backend/app/containerdb/delete_test.go create mode 100644 application/backend/app/containerdb/limit_test.go create mode 100644 application/backend/app/db/reap_test.go create mode 100644 application/backend/app/db/token_test.go create mode 100644 application/backend/app/routes/loginlimit.go create mode 100644 application/backend/app/userdb/password.go create mode 100644 application/backend/app/userdb/password_test.go create mode 100644 application/backend/app/util/inituser_test.go create mode 100644 application/backend/app/util/jwt_test.go create mode 100644 application/backend/app/util/safename_test.go create mode 100644 application/backend/main_test.go create mode 100644 application/frontend/scripts/run-tests.mjs create mode 100644 application/frontend/src/utils/functions.test.mjs diff --git a/README.md b/README.md index 8a3e459..ec66480 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ volumes: ### Docker Run example with traefik ```sh -docker run --restart always -e ADMIN_USERNAME=admin -e PASSWORD= -e PORT=8798 \ +docker run --restart always -e ADMIN_USERNAME=admin -e ADMIN_PASSWORD= -e PORT=8798 \ -v /var/run/docker.sock:/var/run/docker.sock:ro \ -v /var/lib/docker/containers:/var/lib/docker/containers \ -v /etc/hostname:/etc/hostname \ @@ -83,7 +83,7 @@ Once done, just go to and login as "admin" with . |----------------------------|---------------------------------|--------|-----------------| | DOCKER_HOST | URL of the docker socket to connect to. See below | `unix:///var/run/docker.sock` | | | ADMIN_USERNAME | Username for initial user | `admin` | if `AGENT=false` -| ADMIN_PASSWORD | Password for initial user | | if `AGENT=false` +| ADMIN_PASSWORD | Password for initial user. Must not be empty — OnLogs refuses to start without it unless `DISABLE_AUTH=true` | | if `AGENT=false` | PORT | Port to listen on | `2874` | if `AGENT=false` | JWT_SECRET | Secret for JWT tokens for users | Generates randomly | - | ONLOGS_PATH_PREFIX | Base path if you using OnLogs not on subdomain | | only if using on path prefix diff --git a/application/backend/app/containerdb/containerdb.go b/application/backend/app/containerdb/containerdb.go index ef4243f..907cc11 100644 --- a/application/backend/app/containerdb/containerdb.go +++ b/application/backend/app/containerdb/containerdb.go @@ -206,6 +206,11 @@ func findOldestCutoffKey(cutoffKeys [][]byte) []byte { return oldestKey } +const ( + defaultLogsPerRequest = 30 + maxLogsPerRequest = 1000 +) + var ( logCleanupMu sync.Mutex nextCleanup time.Time @@ -248,6 +253,12 @@ func MaybeScheduleCleanup(host string, container string) { } func PutLogMessage(db *leveldb.DB, host string, container string, message_item []string) error { + if db == nil { + return fmt.Errorf("no database for %s/%s", host, container) + } + if len(message_item) < 2 { + return fmt.Errorf("malformed log line for %s/%s", host, container) + } if len(message_item[0]) < 30 { fmt.Println("WARNING: got broken timestamp: ", "timestamp: "+message_item[0], "message: "+message_item[1]) return nil @@ -284,18 +295,24 @@ func PutLogMessage(db *leveldb.DB, host string, container string, message_item [ return err } -func fitsForSearch(logLine string, message string, caseSensetivity bool) bool { - logLine = ansiEscapeRegex.ReplaceAllString(logLine, "") - message = ansiEscapeRegex.ReplaceAllString(message, "") - logLine = strings.Join(strings.Fields(logLine), " ") - message = strings.Join(strings.Fields(message), " ") - +func normalizeForSearch(s string, caseSensetivity bool) string { + s = ansiEscapeRegex.ReplaceAllString(s, "") + s = strings.Join(strings.Fields(s), " ") if !caseSensetivity { - logLine = strings.ToLower(logLine) - message = strings.ToLower(message) + s = strings.ToLower(s) } + return s +} + +func fitsForSearch(logLine string, message string, caseSensetivity bool) bool { + return fitsNormalizedSearch(logLine, normalizeForSearch(message, caseSensetivity), caseSensetivity) +} - return strings.Contains(logLine, message) +func fitsNormalizedSearch(logLine string, normalizedMessage string, caseSensetivity bool) bool { + if normalizedMessage == "" { + return true + } + return strings.Contains(normalizeForSearch(logLine, caseSensetivity), normalizedMessage) } func increaseAndMove(counter *int, move_direction func() bool) { @@ -339,7 +356,17 @@ returns json obj like this: } */ func GetLogs(getPrev bool, include bool, host string, container string, message string, limit int, startWith string, caseSensetivity bool, status *string) map[string]interface{} { + if limit <= 0 { + limit = defaultLogsPerRequest + } else if limit > maxLogsPerRequest { + limit = maxLogsPerRequest + } + logs_db := util.GetDB(host, container, "logs") + if logs_db == nil { + return map[string]interface{}{"logs": [][]string{}, "last_processed_key": "", "is_end": true} + } + var statusDb *leveldb.DB if status != nil { statusDb = util.GetDB(host, container, "statuses") @@ -361,6 +388,7 @@ func GetLogs(getPrev bool, include bool, host string, container string, message counter := 0 iteration := 0 last_processed_key := "" + normalizedMessage := normalizeForSearch(message, caseSensetivity) for counter < limit && iteration < 1000000 { iteration += 1 key := iter.Key() @@ -388,7 +416,7 @@ func GetLogs(getPrev bool, include bool, host string, container string, message } } - if !fitsForSearch(value, message, caseSensetivity) { + if !fitsNormalizedSearch(value, normalizedMessage, caseSensetivity) { move_direction() continue } @@ -404,6 +432,12 @@ func GetLogs(getPrev bool, include bool, host string, container string, message } func DeleteContainer(host string, container string, fullDelete bool) { + // Both callers are bare goroutines, so this rejects rather than panics. + if !util.IsSafeName(host) || !util.IsSafeName(container) { + fmt.Println("ERROR: refusing to delete container with unsafe name:", host, container) + return + } + path := "leveldb/hosts/" + host + "/containers/" + container if fullDelete { os.RemoveAll(path) diff --git a/application/backend/app/containerdb/delete_test.go b/application/backend/app/containerdb/delete_test.go new file mode 100644 index 0000000..9dca95a --- /dev/null +++ b/application/backend/app/containerdb/delete_test.go @@ -0,0 +1,46 @@ +package containerdb + +import ( + "os" + "testing" +) + +func TestDeleteContainerCannotRemoveATreeOutsideLeveldb(t *testing.T) { + t.Chdir(t.TempDir()) + + if err := os.MkdirAll("leveldb/hosts/realhost/containers", 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll("victim_tree/nested", 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile("victim_tree/nested/important", []byte("keep me"), 0o600); err != nil { + t.Fatal(err) + } + + DeleteContainer("realhost", "../../../../victim_tree", true) + + if _, err := os.Stat("victim_tree/nested/important"); err != nil { + t.Fatalf("an admin delete removed a tree outside leveldb/hosts: %v", err) + } +} + +func TestDeleteContainerLogsCannotEmptyATreeOutsideLeveldb(t *testing.T) { + t.Chdir(t.TempDir()) + + if err := os.MkdirAll("leveldb/hosts/realhost/containers", 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll("victim_tree/nested", 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile("victim_tree/nested/important", []byte("keep me"), 0o600); err != nil { + t.Fatal(err) + } + + DeleteContainer("realhost", "../../../../victim_tree", false) + + if _, err := os.Stat("victim_tree/nested/important"); err != nil { + t.Fatalf("a clear-logs request emptied a tree outside leveldb/hosts: %v", err) + } +} diff --git a/application/backend/app/containerdb/limit_test.go b/application/backend/app/containerdb/limit_test.go new file mode 100644 index 0000000..3a91f0c --- /dev/null +++ b/application/backend/app/containerdb/limit_test.go @@ -0,0 +1,75 @@ +package containerdb + +import ( + "os" + "strconv" + "testing" + + "github.com/devforth/OnLogs/app/vars" + "github.com/syndtr/goleveldb/leveldb" +) + +func seedLogs(t *testing.T, host, container string, n int) { + t.Helper() + _ = os.RemoveAll("leveldb/hosts/" + host + "/containers/" + container) + vars.Container_Stat_Counter[host+"/"+container] = map[string]uint64{"error": 0, "debug": 0, "info": 0, "warn": 0, "meta": 0, "other": 0} + + db, err := leveldb.OpenFile("leveldb/hosts/"+host+"/containers/"+container+"/logs", nil) + if err != nil { + t.Fatal(err) + } + statusDB, err := leveldb.OpenFile("leveldb/hosts/"+host+"/containers/"+container+"/statuses", nil) + if err != nil { + t.Fatal(err) + } + vars.Statuses_DBs[host+"/"+container] = statusDB + + for i := 0; i < n; i++ { + ts := vars.Year + "-02-10T12:" + pad(i/60) + ":" + pad(i%60) + ".230421754Z" + if err := PutLogMessage(db, host, container, []string{ts, "line " + strconv.Itoa(i)}); err != nil { + t.Fatal(err) + } + } + db.Close() + statusDB.Close() + delete(vars.Statuses_DBs, host+"/"+container) +} + +func pad(v int) string { + s := strconv.Itoa(v) + if len(s) == 1 { + return "0" + s + } + return s +} + +func TestGetLogsAlwaysReportsIsEnd(t *testing.T) { + seedLogs(t, "LimitHost", "LimitCont", 5) + + for _, limit := range []int{-1, 0, 1, 5, 30} { + result := GetLogs(false, false, "LimitHost", "LimitCont", "", limit, "", false, nil) + if _, ok := result["is_end"]; !ok { + t.Errorf("limit=%d: response omits is_end, so the client cannot tell it has finished", limit) + } + } +} + +func TestGetLogsTreatsANonPositiveLimitAsADefaultPage(t *testing.T) { + seedLogs(t, "LimitHost", "LimitCont", 5) + + for _, limit := range []int{0, -1, -1000} { + logs := GetLogs(false, false, "LimitHost", "LimitCont", "", limit, "", false, nil)["logs"].([][]string) + if len(logs) != 5 { + t.Errorf("limit=%d returned %d rows, want all 5", limit, len(logs)) + } + } +} + +func TestGetLogsClampsAnEnormousLimit(t *testing.T) { + seedLogs(t, "ClampHost", "ClampCont", 1100) + + logs := GetLogs(false, false, "ClampHost", "ClampCont", "", 1<<30, "", false, nil)["logs"].([][]string) + if len(logs) > maxLogsPerRequest { + t.Fatalf("a caller-supplied limit of 2^30 returned %d rows; the response is unbounded", len(logs)) + } +} diff --git a/application/backend/app/daemon/daemon.go b/application/backend/app/daemon/daemon.go index c1c7aba..0193205 100644 --- a/application/backend/app/daemon/daemon.go +++ b/application/backend/app/daemon/daemon.go @@ -136,6 +136,9 @@ func (h *DaemonService) isRecentDuplicate(containerName, fingerprint string) boo func (h *DaemonService) getResumeSince(host, containerName string) time.Time { db := util.GetDB(host, containerName, "streamstate") + if db == nil { + return time.Now().Add(-initialBackfill) + } raw, err := db.Get([]byte(cursorKey), nil) if err != nil || len(raw) == 0 { return time.Now().Add(-initialBackfill) @@ -151,6 +154,9 @@ func (h *DaemonService) getResumeSince(host, containerName string) time.Time { func (h *DaemonService) saveCursor(host, containerName string, ts time.Time) { db := util.GetDB(host, containerName, "streamstate") + if db == nil { + return + } err := db.Put([]byte(cursorKey), []byte(ts.UTC().Format(streamTimestampFmt)), nil) if err != nil { fmt.Println("ERROR: unable to save stream cursor:", err) diff --git a/application/backend/app/db/db.go b/application/backend/app/db/db.go index 2f50a59..349226d 100644 --- a/application/backend/app/db/db.go +++ b/application/backend/app/db/db.go @@ -1,6 +1,7 @@ package db import ( + "strings" "time" "github.com/devforth/OnLogs/app/util" @@ -8,9 +9,57 @@ import ( "github.com/syndtr/goleveldb/leveldb" ) +const ( + tokenTTL = 24 * time.Hour + legacyClaimed = "was used" +) + +// Stored as "|"; an empty claimedAt means never claimed. +type tokenRecord struct { + expiry time.Time + claimedAt time.Time + claimed bool +} + +func encodeToken(r tokenRecord) string { + claimed := "" + if r.claimed { + claimed = r.claimedAt.UTC().Format(time.RFC3339Nano) + } + return r.expiry.UTC().Format(time.RFC3339Nano) + "|" + claimed +} + +func decodeToken(raw string) (tokenRecord, bool) { + // Tokens issued before the expiry was tracked carry this literal and have no + // recoverable timestamps; they stay valid so agents survive the upgrade. + if raw == legacyClaimed { + return tokenRecord{claimed: true}, true + } + + expiryStr, claimedStr, ok := strings.Cut(raw, "|") + if !ok { + return tokenRecord{}, false + } + expiry, err := time.Parse(time.RFC3339Nano, expiryStr) + if err != nil { + return tokenRecord{}, false + } + r := tokenRecord{expiry: expiry} + if claimedStr != "" { + claimedAt, err := time.Parse(time.RFC3339Nano, claimedStr) + if err != nil { + return tokenRecord{}, false + } + r.claimedAt = claimedAt + r.claimed = true + } + return r, true +} + func CreateOnLogsToken() string { token := util.GenerateJWTSecret() - to_put := time.Now().UTC().Add(24 * time.Hour).String() + to_put := encodeToken(tokenRecord{expiry: time.Now().UTC().Add(tokenTTL)}) + err := vars.TokensDB.Put([]byte(token), []byte(to_put), nil) if err != nil { vars.TokensDB.Close() @@ -25,39 +74,49 @@ func CreateOnLogsToken() string { } func IsTokenExists(token string) bool { - iter := vars.TokensDB.NewIterator(nil, nil) - defer iter.Release() - iter.First() - if string(iter.Key()) == token { - vars.TokensDB.Put([]byte(token), []byte("was used"), nil) + if token == "" || vars.TokensDB == nil { + return false + } + + raw, err := vars.TokensDB.Get([]byte(token), nil) + if err != nil { + return false + } + + record, ok := decodeToken(string(raw)) + if !ok { + return false + } + if record.claimed { return true } + if record.expiry.Before(time.Now()) { + return false + } + + record.claimedAt = time.Now() + record.claimed = true + vars.TokensDB.Put([]byte(token), []byte(encodeToken(record)), nil) + return true +} + +func reapExpiredTokens(db *leveldb.DB) { + iter := db.NewIterator(nil, nil) + defer iter.Release() + for iter.Next() { - if string(iter.Key()) == token { - vars.TokensDB.Put([]byte(token), []byte("was used"), nil) - return true + record, ok := decodeToken(string(iter.Value())) + if !ok || (!record.claimed && record.expiry.Before(time.Now())) { + db.Delete(iter.Key(), nil) } } - return false } func DeleteUnusedTokens() { for { - db := vars.TokensDB - iter := db.NewIterator(nil, nil) - for iter.Next() { - wasUsed := string(iter.Value()) - if wasUsed == "was used" { - continue - } - - created, _ := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", string(wasUsed)) - if created.Before(time.Now()) { - db.Delete(iter.Key(), nil) - } - } - iter.Release() - db.Close() time.Sleep(time.Hour * 1) + if vars.TokensDB != nil { + reapExpiredTokens(vars.TokensDB) + } } } diff --git a/application/backend/app/db/reap_test.go b/application/backend/app/db/reap_test.go new file mode 100644 index 0000000..9e0fc08 --- /dev/null +++ b/application/backend/app/db/reap_test.go @@ -0,0 +1,56 @@ +package db + +import ( + "testing" + "time" + + "github.com/devforth/OnLogs/app/vars" +) + +func TestReapExpiredTokensRemovesOnlyUnclaimedExpiredOnes(t *testing.T) { + claimed := "reap-claimed-probe" + unclaimedFresh := "reap-unclaimed-fresh-probe" + unclaimedStale := "reap-unclaimed-stale-probe" + garbage := "reap-garbage-probe" + t.Cleanup(func() { + for _, k := range []string{claimed, unclaimedFresh, unclaimedStale, garbage} { + vars.TokensDB.Delete([]byte(k), nil) + } + }) + + past := time.Now().UTC().Add(-time.Hour) + future := time.Now().UTC().Add(time.Hour) + vars.TokensDB.Put([]byte(claimed), []byte(encodeToken(tokenRecord{expiry: past, claimedAt: past, claimed: true})), nil) + vars.TokensDB.Put([]byte(unclaimedFresh), []byte(encodeToken(tokenRecord{expiry: future})), nil) + vars.TokensDB.Put([]byte(unclaimedStale), []byte(encodeToken(tokenRecord{expiry: past})), nil) + vars.TokensDB.Put([]byte(garbage), []byte("not a timestamp at all"), nil) + + reapExpiredTokens(vars.TokensDB) + + for _, keep := range []string{claimed, unclaimedFresh} { + if has, _ := vars.TokensDB.Has([]byte(keep), nil); !has { + t.Errorf("%s was reaped but should have been kept", keep) + } + } + for _, gone := range []string{unclaimedStale, garbage} { + if has, _ := vars.TokensDB.Has([]byte(gone), nil); has { + t.Errorf("%s survived the reaper", gone) + } + } +} + +func TestLegacyClaimedTokensKeepWorking(t *testing.T) { + legacy := "legacy-was-used-probe" + if err := vars.TokensDB.Put([]byte(legacy), []byte(legacyClaimed), nil); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { vars.TokensDB.Delete([]byte(legacy), nil) }) + + if !IsTokenExists(legacy) { + t.Fatal("an agent token issued before the upgrade stopped working") + } + reapExpiredTokens(vars.TokensDB) + if has, _ := vars.TokensDB.Has([]byte(legacy), nil); !has { + t.Fatal("an agent token issued before the upgrade was reaped") + } +} diff --git a/application/backend/app/db/token_test.go b/application/backend/app/db/token_test.go new file mode 100644 index 0000000..397b802 --- /dev/null +++ b/application/backend/app/db/token_test.go @@ -0,0 +1,101 @@ +package db + +import ( + "strings" + "testing" + "time" + + "github.com/devforth/OnLogs/app/vars" +) + +func TestIsTokenExistsKeepsTheTokenAuditable(t *testing.T) { + token := CreateOnLogsToken() + + before, err := vars.TokensDB.Get([]byte(token), nil) + if err != nil { + t.Fatal(err) + } + issued := strings.Split(string(before), "|")[0] + if _, err := time.Parse(time.RFC3339Nano, issued); err != nil { + t.Fatalf("a fresh token does not carry a parseable expiry: %q", before) + } + + if !IsTokenExists(token) { + t.Fatal("a freshly minted token was rejected") + } + + after, err := vars.TokensDB.Get([]byte(token), nil) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(after), issued+"|") { + t.Fatalf("using a token destroyed its expiry: %q -> %q", before, after) + } + if strings.HasSuffix(string(after), "|") { + t.Fatalf("using a token did not record when it was claimed: %q", after) + } +} + +func TestIsTokenExistsWritesOnlyOnFirstUse(t *testing.T) { + token := CreateOnLogsToken() + + if !IsTokenExists(token) { + t.Fatal("a freshly minted token was rejected") + } + first, _ := vars.TokensDB.Get([]byte(token), nil) + + time.Sleep(5 * time.Millisecond) + if !IsTokenExists(token) { + t.Fatal("a claimed token was rejected") + } + second, _ := vars.TokensDB.Get([]byte(token), nil) + + if string(first) != string(second) { + t.Fatalf("every ingested log line rewrites the token record: %q -> %q", first, second) + } +} + +func TestIsTokenExistsRejectsTheEmptyToken(t *testing.T) { + if IsTokenExists("") { + t.Fatal("the empty token authenticates") + } +} + +func TestIsTokenExistsRejectsAnUnclaimedExpiredToken(t *testing.T) { + expired := "expired-token-probe" + stamp := time.Now().UTC().Add(-time.Hour).Format(time.RFC3339Nano) + "|" + if err := vars.TokensDB.Put([]byte(expired), []byte(stamp), nil); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { vars.TokensDB.Delete([]byte(expired), nil) }) + + if IsTokenExists(expired) { + t.Fatalf("an unclaimed token that expired an hour ago still authenticates (%q)", stamp) + } +} + +func TestIsTokenExistsRejectsAGarbageRecord(t *testing.T) { + junk := "garbage-token-probe" + if err := vars.TokensDB.Put([]byte(junk), []byte("not a timestamp at all"), nil); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { vars.TokensDB.Delete([]byte(junk), nil) }) + + if IsTokenExists(junk) { + t.Fatal("a token record with no recoverable expiry still authenticates") + } +} + +func TestDeleteUnusedTokensKeepsTheSharedHandleOpen(t *testing.T) { + token := CreateOnLogsToken() + + go DeleteUnusedTokens() + time.Sleep(250 * time.Millisecond) + + if !IsTokenExists(token) { + t.Error("a valid token stopped being accepted once the token reaper ran") + } + if IsTokenExists("") { + t.Error("the empty token authenticates once the token reaper ran") + } +} diff --git a/application/backend/app/routes/loginlimit.go b/application/backend/app/routes/loginlimit.go new file mode 100644 index 0000000..892d817 --- /dev/null +++ b/application/backend/app/routes/loginlimit.go @@ -0,0 +1,100 @@ +package routes + +import ( + "net" + "net/http" + "sync" + "time" +) + +const ( + freeLoginAttempts = 5 + maxLoginBackoff = 15 * time.Minute +) + +type loginAttempts struct { + mu sync.Mutex + entries map[string]*loginAttempt +} + +type loginAttempt struct { + failures int + blocked time.Time +} + +var loginLimiter = &loginAttempts{entries: map[string]*loginAttempt{}} + +func backoffFor(failures int) time.Duration { + if failures <= freeLoginAttempts { + return 0 + } + backoff := time.Second << (failures - freeLoginAttempts - 1) + if backoff > maxLoginBackoff || backoff <= 0 { + return maxLoginBackoff + } + return backoff +} + +func (l *loginAttempts) allow(keys ...string) bool { + now := time.Now() + + l.mu.Lock() + defer l.mu.Unlock() + l.prune(now) + + for _, key := range keys { + if entry, ok := l.entries[key]; ok && now.Before(entry.blocked) { + return false + } + } + return true +} + +func (l *loginAttempts) fail(keys ...string) { + now := time.Now() + + l.mu.Lock() + defer l.mu.Unlock() + l.prune(now) + + for _, key := range keys { + entry, ok := l.entries[key] + if !ok { + entry = &loginAttempt{} + l.entries[key] = entry + } + entry.failures++ + if backoff := backoffFor(entry.failures); backoff > 0 { + entry.blocked = now.Add(backoff) + } + } +} + +func (l *loginAttempts) succeed(keys ...string) { + l.mu.Lock() + defer l.mu.Unlock() + for _, key := range keys { + delete(l.entries, key) + } +} + +func (l *loginAttempts) prune(now time.Time) { + for key, entry := range l.entries { + if entry.blocked.IsZero() || now.After(entry.blocked.Add(maxLoginBackoff)) { + if entry.failures <= freeLoginAttempts && entry.blocked.IsZero() { + continue + } + delete(l.entries, key) + } + } +} + +// RemoteAddr only: X-Forwarded-For is caller-controlled and would let an +// attacker rotate past the limit. +func clientAddr(req *http.Request) string { + host, _, err := net.SplitHostPort(req.RemoteAddr) + if err != nil { + return req.RemoteAddr + } + return host +} diff --git a/application/backend/app/routes/routes.go b/application/backend/app/routes/routes.go index 4fe00ad..c9a617f 100644 --- a/application/backend/app/routes/routes.go +++ b/application/backend/app/routes/routes.go @@ -7,6 +7,7 @@ import ( "io" "mime" "net/http" + "net/url" "os" "path/filepath" "strconv" @@ -42,6 +43,21 @@ func enableCors(w *http.ResponseWriter) { (*w).Header().Set("Access-Control-Allow-Headers", "Content-Type") } +func isAllowedOrigin(req *http.Request) bool { + origin := req.Header.Get("Origin") + if origin == "" { + return true + } + parsed, err := url.Parse(origin) + if err != nil { + return false + } + if os.Getenv("ENV_NAME") == "local" && parsed.Host == "localhost:5173" { + return true + } + return strings.EqualFold(parsed.Host, req.Host) +} + func verifyAdminUser(w *http.ResponseWriter, req *http.Request) bool { if os.Getenv("DISABLE_AUTH") == "true" { return true @@ -76,6 +92,28 @@ func verifyUser(w *http.ResponseWriter, req *http.Request) bool { return true } +const ( + maxRequestBody = 1 << 20 + sessionLifetime = 48 * time.Hour +) + +func isTLS(req *http.Request) bool { + return req.TLS != nil || strings.EqualFold(req.Header.Get("X-Forwarded-Proto"), "https") +} + +// Bounds the body and, unlike the bare decoder it replaces, does not discard the +// decode error. +func decodeBody(w http.ResponseWriter, req *http.Request, target interface{}) bool { + req.Body = http.MaxBytesReader(w, req.Body, maxRequestBody) + if err := json.NewDecoder(req.Body).Decode(target); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Invalid request body"}) + return false + } + return true +} + func verifyRequest(w *http.ResponseWriter, req *http.Request) bool { enableCors(w) if req.Method == "OPTIONS" { @@ -86,9 +124,7 @@ func verifyRequest(w *http.ResponseWriter, req *http.Request) bool { } func (h *RouteController)Frontend(w http.ResponseWriter, req *http.Request) { - // The root must be a constant. http.Dir sanitises the name it is given but - // never its own root, so anything caller-controlled in the root (the query - // string included) is an arbitrary file read. + // http.Dir sanitises the name it is given but never its own root. dir := http.Dir("dist") fileName := strings.TrimPrefix(strings.TrimPrefix(req.URL.Path, os.Getenv("ONLOGS_PATH_PREFIX")), "/") @@ -140,14 +176,20 @@ func (h *RouteController)AddLogLine(w http.ResponseWriter, req *http.Request) { Container string LogLine []string } - decoder := json.NewDecoder(req.Body) - decoder.Decode(&logItem) + if !decodeBody(w, req, &logItem) { + return + } if !db.IsTokenExists(logItem.Token) { w.WriteHeader(http.StatusUnauthorized) return } + if !util.IsSafeName(logItem.Host) || !util.IsSafeName(logItem.Container) || len(logItem.LogLine) < 2 { + w.WriteHeader(http.StatusBadRequest) + return + } + if vars.Counters_For_Hosts_Last_30_Min[logItem.Host] == nil { go statistics.RunStatisticForContainer(logItem.Host, logItem.Container) } @@ -176,14 +218,26 @@ func (h *RouteController)AddHost(w http.ResponseWriter, req *http.Request) { Token string Services []string } - decoder := json.NewDecoder(req.Body) - decoder.Decode(&addReq) + if !decodeBody(w, req, &addReq) { + return + } if !db.IsTokenExists(addReq.Token) { w.WriteHeader(http.StatusUnauthorized) return } + if !util.IsSafeName(addReq.Hostname) { + w.WriteHeader(http.StatusBadRequest) + return + } + for _, container := range addReq.Services { + if !util.IsSafeName(container) { + w.WriteHeader(http.StatusBadRequest) + return + } + } + vars.AgentsActiveContainers[addReq.Hostname] = addReq.Services // fmt.Println("New host added: " + addReq.Hostname) need to create separate route for SendUpdate func for _, container := range addReq.Services { @@ -205,8 +259,9 @@ func (h *RouteController)ChangeFavourite(w http.ResponseWriter, req *http.Reques Host string `json:"host"` Service string `json:"service"` } - decoder := json.NewDecoder(req.Body) - decoder.Decode(&container) + if !decodeBody(w, req, &container) { + return + } key := []byte(container.Host + "/" + container.Service) isAlreadyFavourite, _ := vars.FavsDB.Has(key, nil) @@ -221,7 +276,7 @@ func (h *RouteController)ChangeFavourite(w http.ResponseWriter, req *http.Reques } func (h *RouteController)GetSecret(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { + if verifyRequest(&w, req) || !verifyAdminUser(&w, req) { return } @@ -245,11 +300,7 @@ func (h *RouteController)GetChartData(w http.ResponseWriter, req *http.Request) Unit string `json:"unit"` UnitsAmount int `json:"unitsAmount"` } - decoder := json.NewDecoder(req.Body) - err := decoder.Decode(&data) - if err != nil { - w.Header().Add("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"error": "Invalid data!"}) + if !decodeBody(w, req, &data) { return } @@ -382,12 +433,7 @@ func (h *RouteController)GetStats(w http.ResponseWriter, req *http.Request) { Value int `json:"period"` // 1 = 30min, 2 = 1hr, 48 = 1d } - decoder := json.NewDecoder(req.Body) - err := decoder.Decode(&data) - - if err != nil { - w.Header().Add("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"error": "Invalid data!"}) + if !decodeBody(w, req, &data) { return } w.Header().Add("Content-Type", "application/json") @@ -403,14 +449,10 @@ func (h *RouteController)GetStorageData(w http.ResponseWriter, req *http.Request Host string `json:"host"` } - decoder := json.NewDecoder(req.Body) - err := decoder.Decode(&data) - - w.Header().Add("Content-Type", "application/json") - if err != nil { - json.NewEncoder(w).Encode(map[string]string{"error": "Invalid data!"}) + if !decodeBody(w, req, &data) { return } + w.Header().Add("Content-Type", "application/json") // TODO make for different hosts if data.Host != util.GetHost() { @@ -509,12 +551,15 @@ func (h *RouteController)GetLogsStream(w http.ResponseWriter, req *http.Request) var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, + CheckOrigin: isAllowedOrigin, } - upgrader.CheckOrigin = func(r *http.Request) bool { return true } // verify req here? ws, err := upgrader.Upgrade(w, req, nil) if err != nil { + // gorilla returns (nil, err); appending here seeds a nil conn that a + // background goroutine later dereferences. fmt.Println(err) + return } vars.Connections[container] = append(vars.Connections[container], ws) } @@ -531,20 +576,34 @@ func (h *RouteController)Login(w http.ResponseWriter, req *http.Request) { } var loginData vars.UserData - decoder := json.NewDecoder(req.Body) - decoder.Decode(&loginData) + if !decodeBody(w, req, &loginData) { + return + } + + ipKey := "ip:" + clientAddr(req) + loginKey := "login:" + loginData.Login + if !loginLimiter.allow(ipKey, loginKey) { + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + json.NewEncoder(w).Encode(map[string]string{"error": "Too many failed login attempts. Try again later."}) + return + } isCorrect := userdb.CheckUserPassword(loginData.Login, loginData.Password) if !isCorrect { + loginLimiter.fail(ipKey, loginKey) json.NewEncoder(w).Encode(map[string]string{"error": "Wrong login or password!"}) return } + loginLimiter.succeed(ipKey, loginKey) http.SetCookie(w, &http.Cookie{ Name: "onlogs-cookie", Value: util.CreateJWT(loginData.Login), Expires: time.Now().AddDate(0, 0, 2), - MaxAge: int(time.Now().AddDate(0, 0, 2).Unix()), + MaxAge: int(sessionLifetime / time.Second), + HttpOnly: true, + Secure: isTLS(req), SameSite: http.SameSiteLaxMode, Path: "/", }) @@ -560,7 +619,9 @@ func (h *RouteController)Logout(w http.ResponseWriter, req *http.Request) { Name: "onlogs-cookie", Value: "toDelete", Expires: time.Now().AddDate(-5, -5, -5), - MaxAge: 0, + MaxAge: -1, + HttpOnly: true, + Secure: isTLS(req), SameSite: http.SameSiteLaxMode, Path: "/", }) @@ -579,8 +640,9 @@ func (h *RouteController)CreateUser(w http.ResponseWriter, req *http.Request) { } var loginData vars.UserData - decoder := json.NewDecoder(req.Body) - decoder.Decode(&loginData) + if !decodeBody(w, req, &loginData) { + return + } err := userdb.CreateUser(loginData.Login, loginData.Password) if err == nil { @@ -592,7 +654,7 @@ func (h *RouteController)CreateUser(w http.ResponseWriter, req *http.Request) { } func (h *RouteController)GetUsers(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { + if verifyRequest(&w, req) || !verifyAdminUser(&w, req) { return } @@ -607,8 +669,9 @@ func (h *RouteController)UpdateUserSettings(w http.ResponseWriter, req *http.Req } var settings map[string]interface{} - body, _ := io.ReadAll(req.Body) - json.Unmarshal(body, &settings) + if !decodeBody(w, req, &settings) { + return + } username, _ := util.GetUserFromJWT(*req) userdb.UpdateUserSettings(username, settings) json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) @@ -633,8 +696,9 @@ func (h *RouteController)EditHostname(w http.ResponseWriter, req *http.Request) Host string Name string } - decoder := json.NewDecoder(req.Body) - decoder.Decode(&data) + if !decodeBody(w, req, &data) { + return + } if data.Name != "" { if data.Host != util.GetHost() { @@ -654,8 +718,9 @@ func (h *RouteController)EditUser(w http.ResponseWriter, req *http.Request) { } var loginData vars.UserData - decoder := json.NewDecoder(req.Body) - decoder.Decode(&loginData) + if !decodeBody(w, req, &loginData) { + return + } if loginData.Login == os.Getenv("ADMIN_USERNAME") { w.Header().Add("Content-Type", "application/json") @@ -681,8 +746,9 @@ func (h *RouteController)DeleteContainerLogs(w http.ResponseWriter, req *http.Re Host string `json:"host"` Service string `json:"service"` } - decoder := json.NewDecoder(req.Body) - decoder.Decode(&containerItem) + if !decodeBody(w, req, &containerItem) { + return + } go containerdb.DeleteContainer(containerItem.Host, containerItem.Service, false) w.Header().Add("Content-Type", "application/json") @@ -698,8 +764,9 @@ func (h *RouteController)DeleteDockerLogs(w http.ResponseWriter, req *http.Reque Host string Service string } - decoder := json.NewDecoder(req.Body) - decoder.Decode(&logItem) + if !decodeBody(w, req, &logItem) { + return + } w.Header().Add("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"error": util.DeleteDockerLogs(logItem.Host, logItem.Service)}) @@ -710,8 +777,9 @@ func (h *RouteController)AskForDelete(w http.ResponseWriter, req *http.Request) Hostname string Token string } - decoder := json.NewDecoder(req.Body) - decoder.Decode(&logItem) + if !decodeBody(w, req, &logItem) { + return + } if !db.IsTokenExists(logItem.Token) { w.WriteHeader(http.StatusUnauthorized) @@ -737,8 +805,9 @@ func (h *RouteController)DeleteContainer(w http.ResponseWriter, req *http.Reques Host string Service string } - decoder := json.NewDecoder(req.Body) - decoder.Decode(&logItem) + if !decodeBody(w, req, &logItem) { + return + } if logItem.Host == "" || logItem.Host == util.GetHost() { dockerContainerID := util.GetDockerContainerID(logItem.Host, logItem.Service) @@ -768,8 +837,9 @@ func (h *RouteController)DeleteUser(w http.ResponseWriter, req *http.Request) { var loginData struct { Login string `json:"login"` } - decoder := json.NewDecoder(req.Body) - decoder.Decode(&loginData) + if !decodeBody(w, req, &loginData) { + return + } if loginData.Login == os.Getenv("ADMIN_USERNAME") { w.Header().Add("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"error": "Can't delete admin"}) diff --git a/application/backend/app/routes/routes_test.go b/application/backend/app/routes/routes_test.go index 5ed960f..d87a444 100644 --- a/application/backend/app/routes/routes_test.go +++ b/application/backend/app/routes/routes_test.go @@ -21,6 +21,14 @@ import ( "github.com/syndtr/goleveldb/leveldb" ) +// These tests used to pass with JWT_SECRET unset, which verifies any forged token. +func TestMain(m *testing.M) { + if os.Getenv("JWT_SECRET") == "" { + os.Setenv("JWT_SECRET", "routes-package-test-signing-key") + } + os.Exit(m.Run()) +} + func initTestConfig() *RouteController { cli, _ := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) defer cli.Close() diff --git a/application/backend/app/routes/security_test.go b/application/backend/app/routes/security_test.go index 8a8c0e9..e40c017 100644 --- a/application/backend/app/routes/security_test.go +++ b/application/backend/app/routes/security_test.go @@ -1,17 +1,25 @@ package routes import ( + "crypto/tls" + "strconv" + + "bytes" + "encoding/json" + "github.com/devforth/OnLogs/app/userdb" + "github.com/devforth/OnLogs/app/vars" + + "github.com/devforth/OnLogs/app/db" "io" "net/http" "net/http/httptest" "os" "strings" "testing" + + "github.com/devforth/OnLogs/app/util" ) -// B1 — Frontend built the http.Dir ROOT from the raw URL including the query -// string, so `?x=/../../` escaped the dist directory entirely and served -// any file on the filesystem to an unauthenticated caller. func TestFrontendDoesNotServeFilesOutsideDist(t *testing.T) { ctrl := initTestConfig() os.MkdirAll("dist", 0o700) @@ -36,9 +44,6 @@ func TestFrontendDoesNotServeFilesOutsideDist(t *testing.T) { } } -// B14 — the prefix was stripped with ReplaceAll at every position, so any asset -// path that repeats the prefix was rewritten into a path that does not exist and -// silently fell back to index.html. func TestFrontendStripsPathPrefixOnlyAtTheFront(t *testing.T) { ctrl := initTestConfig() t.Setenv("ONLOGS_PATH_PREFIX", "/logs") @@ -56,3 +61,204 @@ func TestFrontendStripsPathPrefixOnlyAtTheFront(t *testing.T) { t.Fatalf("expected the asset, got %q", string(body)) } } + +func adminOnlyRequest(t *testing.T, handler http.HandlerFunc, method, target string) int { + t.Helper() + os.Setenv("ADMIN_USERNAME", "admin") + + req, _ := http.NewRequest(method, target, nil) + req.AddCookie(&http.Cookie{Name: "onlogs-cookie", Value: util.CreateJWT("viewer")}) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + return rr.Result().StatusCode +} + +func TestGetSecretIsAdminOnly(t *testing.T) { + ctrl := initTestConfig() + if code := adminOnlyRequest(t, ctrl.GetSecret, "GET", "/api/v1/getSecret"); code != http.StatusForbidden { + t.Fatalf("a non-admin account minted an agent token: status %d", code) + } +} + +func TestGetUsersIsAdminOnly(t *testing.T) { + ctrl := initTestConfig() + if code := adminOnlyRequest(t, ctrl.GetUsers, "GET", "/api/v1/getUsers"); code != http.StatusForbidden { + t.Fatalf("a non-admin account enumerated every username: status %d", code) + } +} + +func TestAddHostRejectsNamesThatLeaveTheTree(t *testing.T) { + ctrl := initTestConfig() + token := db.CreateOnLogsToken() + + for _, payload := range []map[string]interface{}{ + {"Hostname": "../../../../PWNED_HOST", "Token": token, "Services": []string{"c"}}, + {"Hostname": "realhost", "Token": token, "Services": []string{"../../../../PWNED_SERVICE"}}, + } { + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", "/api/v1/addHost", bytes.NewBuffer(body)) + rr := httptest.NewRecorder() + http.HandlerFunc(ctrl.AddHost).ServeHTTP(rr, req) + + if code := rr.Result().StatusCode; code != http.StatusBadRequest { + t.Errorf("addHost accepted %v: status %d", payload["Hostname"], code) + } + } + + for _, leaked := range []string{"PWNED_HOST", "PWNED_SERVICE", "../PWNED_HOST"} { + if _, err := os.Stat(leaked); err == nil { + os.RemoveAll(leaked) + t.Errorf("addHost created %s outside leveldb/hosts", leaked) + } + } +} + +func TestAddLogLineRejectsNamesThatLeaveTheTree(t *testing.T) { + ctrl := initTestConfig() + token := db.CreateOnLogsToken() + + body, _ := json.Marshal(map[string]interface{}{ + "Token": token, + "Host": "../../../../PWNED_INGEST", + "Container": "c", + "LogLine": []string{"2026-02-10T12:56:09.230421754Z", "hello"}, + }) + req, _ := http.NewRequest("POST", "/api/v1/addLogLine", bytes.NewBuffer(body)) + rr := httptest.NewRecorder() + http.HandlerFunc(ctrl.AddLogLine).ServeHTTP(rr, req) + + if code := rr.Result().StatusCode; code != http.StatusBadRequest { + t.Errorf("addLogLine accepted a traversing host: status %d", code) + } + if _, err := os.Stat("../../../../PWNED_INGEST"); err == nil { + os.RemoveAll("../../../../PWNED_INGEST") + t.Error("addLogLine created a directory outside leveldb/hosts") + } +} + +func oversizedJSONBody() []byte { + body := []byte(`{"pad":"`) + body = append(body, bytes.Repeat([]byte("A"), 4<<20)...) + return append(body, []byte(`"}`)...) +} + +func TestHandlersRejectOversizedRequestBodies(t *testing.T) { + ctrl := initTestConfig() + os.Setenv("ADMIN_USERNAME", "admin") + + cases := []struct { + name string + handler http.HandlerFunc + }{ + {"updateUserSettings", ctrl.UpdateUserSettings}, + {"login", ctrl.Login}, + {"changeFavorite", ctrl.ChangeFavourite}, + {"addLogLine", ctrl.AddLogLine}, + } + + for _, c := range cases { + req, _ := http.NewRequest("POST", "/", bytes.NewBuffer(oversizedJSONBody())) + req.AddCookie(&http.Cookie{Name: "onlogs-cookie", Value: util.CreateJWT("admin")}) + rr := httptest.NewRecorder() + c.handler.ServeHTTP(rr, req) + + if code := rr.Result().StatusCode; code == http.StatusOK { + t.Errorf("%s accepted a 4 MiB request body: status %d", c.name, code) + } + } +} + +func TestLoginCookieIsHttpOnlyAndUsesARelativeMaxAge(t *testing.T) { + ctrl := initTestConfig() + userdb.CreateUser("cookieuser", "cookiepass") + + body, _ := json.Marshal(map[string]string{"Login": "cookieuser", "Password": "cookiepass"}) + req, _ := http.NewRequest("POST", "/api/v1/login", bytes.NewBuffer(body)) + rr := httptest.NewRecorder() + http.HandlerFunc(ctrl.Login).ServeHTTP(rr, req) + + cookies := rr.Result().Cookies() + if len(cookies) == 0 { + t.Fatalf("login set no cookie: %s", rr.Body.String()) + } + c := cookies[0] + + if !c.HttpOnly { + t.Error("session cookie is readable from JavaScript, so any XSS lifts the session") + } + if c.MaxAge > 7*24*3600 { + t.Errorf("Max-Age is %d seconds (~%d years); it should be a relative lifetime, not an absolute epoch", + c.MaxAge, c.MaxAge/(365*24*3600)) + } + if c.MaxAge <= 0 { + t.Errorf("Max-Age is %d, the session would not persist", c.MaxAge) + } +} + +func TestLoginCookieIsSecureOverTLS(t *testing.T) { + ctrl := initTestConfig() + userdb.CreateUser("cookieuser", "cookiepass") + + body, _ := json.Marshal(map[string]string{"Login": "cookieuser", "Password": "cookiepass"}) + req, _ := http.NewRequest("POST", "https://onlogs.example/api/v1/login", bytes.NewBuffer(body)) + req.TLS = &tls.ConnectionState{} + rr := httptest.NewRecorder() + http.HandlerFunc(ctrl.Login).ServeHTTP(rr, req) + + cookies := rr.Result().Cookies() + if len(cookies) == 0 { + t.Fatalf("login set no cookie: %s", rr.Body.String()) + } + if !cookies[0].Secure { + t.Error("session cookie issued over TLS is not marked Secure") + } +} + +func TestGetLogsStreamRejectsAForeignOrigin(t *testing.T) { + ctrl := initTestConfig() + + req, _ := http.NewRequest("GET", "/api/v1/getLogsStream?host="+util.GetHost()+"&id=somecontainer", nil) + req.Host = "onlogs.example" + req.Header.Set("Origin", "http://evil.example") + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Sec-WebSocket-Version", "13") + req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==") + req.AddCookie(&http.Cookie{Name: "onlogs-cookie", Value: util.CreateJWT("admin")}) + + rr := httptest.NewRecorder() + http.HandlerFunc(ctrl.GetLogsStream).ServeHTTP(rr, req) + + if code := rr.Result().StatusCode; code != http.StatusForbidden { + t.Errorf("a cross-origin websocket handshake was not rejected: status %d", code) + } + for _, conns := range vars.Connections { + for _, c := range conns { + if c == nil { + t.Fatal("a failed upgrade stored a nil connection that a background goroutine will dereference") + } + } + } +} + +func TestLoginRateLimitsRepeatedFailures(t *testing.T) { + ctrl := initTestConfig() + userdb.CreateUser("ratelimited", "correct-horse") + + post := func(password string) int { + body, _ := json.Marshal(map[string]string{"Login": "ratelimited", "Password": password}) + req, _ := http.NewRequest("POST", "/api/v1/login", bytes.NewBuffer(body)) + req.RemoteAddr = "203.0.113.9:34567" + rr := httptest.NewRecorder() + http.HandlerFunc(ctrl.Login).ServeHTTP(rr, req) + return rr.Result().StatusCode + } + + for i := 0; i < 20; i++ { + post("wrong-" + strconv.Itoa(i)) + } + + if code := post("correct-horse"); code != http.StatusTooManyRequests { + t.Fatalf("20 failed logins from one address did not trigger a backoff: status %d", code) + } +} diff --git a/application/backend/app/statistics/statistics.go b/application/backend/app/statistics/statistics.go index 3a20cc6..93e57fa 100644 --- a/application/backend/app/statistics/statistics.go +++ b/application/backend/app/statistics/statistics.go @@ -14,6 +14,9 @@ import ( func restartStats(host string, container string) { current_db := util.GetDB(host, container, "statistics") + if current_db == nil { + return + } location := host if container != "" { location += "/" + container @@ -150,6 +153,9 @@ func GetStatisticsByService(host string, service string, value int) map[string]u searchTo := time.Now().Add(-(time.Hour * time.Duration(value/2))).UTC() var tmp_stats map[string]uint64 current_db := util.GetDB(host, service, "statistics") + if current_db == nil { + return to_return + } iter := current_db.NewIterator(nil, nil) defer iter.Release() iter.Last() @@ -192,7 +198,11 @@ func GetChartData(host string, service string, unit string, uAmount int) map[str location := host + "/" + service to_return := map[string]map[string]uint64{} - iter := util.GetDB(host, service, "statistics").NewIterator(nil, nil) + statsDB := util.GetDB(host, service, "statistics") + if statsDB == nil { + return to_return + } + iter := statsDB.NewIterator(nil, nil) iter.Last() defer iter.Release() hasPrev := true diff --git a/application/backend/app/userdb/password.go b/application/backend/app/userdb/password.go new file mode 100644 index 0000000..0b8f7a7 --- /dev/null +++ b/application/backend/app/userdb/password.go @@ -0,0 +1,58 @@ +package userdb + +import ( + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "strconv" + "strings" +) + +const ( + hashScheme = "pbkdf2_sha256" + hashIterations = 600000 + hashKeyLength = 32 + hashSaltLength = 16 +) + +func HashPassword(password string) string { + salt := make([]byte, hashSaltLength) + if _, err := rand.Read(salt); err != nil { + panic(err) + } + return encodeHash(password, salt, hashIterations) +} + +func encodeHash(password string, salt []byte, iterations int) string { + key, err := pbkdf2.Key(sha256.New, password, salt, iterations, hashKeyLength) + if err != nil { + panic(err) + } + return strings.Join([]string{ + hashScheme, + strconv.Itoa(iterations), + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(key), + }, "$") +} + +func verifyHash(stored string, password string) (ok bool, wasHashed bool) { + parts := strings.Split(stored, "$") + if len(parts) != 4 || parts[0] != hashScheme { + return false, false + } + + iterations, err := strconv.Atoi(parts[1]) + if err != nil || iterations <= 0 { + return false, true + } + salt, err := base64.RawStdEncoding.DecodeString(parts[2]) + if err != nil { + return false, true + } + + candidate := encodeHash(password, salt, iterations) + return subtle.ConstantTimeCompare([]byte(candidate), []byte(stored)) == 1, true +} diff --git a/application/backend/app/userdb/password_test.go b/application/backend/app/userdb/password_test.go new file mode 100644 index 0000000..bf8b56a --- /dev/null +++ b/application/backend/app/userdb/password_test.go @@ -0,0 +1,85 @@ +package userdb + +import ( + "strings" + "testing" + + "github.com/devforth/OnLogs/app/vars" +) + +func TestCheckUserPasswordRejectsAnEmptyPassword(t *testing.T) { + if err := vars.UsersDB.Put([]byte("emptypwuser"), []byte(""), nil); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { vars.UsersDB.Delete([]byte("emptypwuser"), nil) }) + + if CheckUserPassword("emptypwuser", "") { + t.Fatal("a user stored with an empty password authenticated with an empty password") + } +} + +func TestCheckUserPasswordRejectsUnknownUser(t *testing.T) { + if CheckUserPassword("no-such-user-at-all", "") { + t.Fatal("unknown user authenticated with an empty password") + } + if CheckUserPassword("no-such-user-at-all", "anything") { + t.Fatal("unknown user authenticated") + } +} + +func TestStoredPasswordsAreNotPlaintext(t *testing.T) { + const secret = "correct horse battery staple" + CreateUser("hasheduser", secret) + t.Cleanup(func() { vars.UsersDB.Delete([]byte("hasheduser"), nil) }) + + stored, err := vars.UsersDB.Get([]byte("hasheduser"), nil) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(stored), secret) { + t.Fatalf("the password is stored in cleartext: %q", stored) + } + if !CheckUserPassword("hasheduser", secret) { + t.Fatal("the correct password no longer verifies") + } + if CheckUserPassword("hasheduser", secret+"x") { + t.Fatal("a wrong password verifies") + } +} + +func TestEditUserStoresAHash(t *testing.T) { + CreateUser("edithashuser", "first-password") + t.Cleanup(func() { vars.UsersDB.Delete([]byte("edithashuser"), nil) }) + + EditUser("edithashuser", "second-password") + + stored, _ := vars.UsersDB.Get([]byte("edithashuser"), nil) + if strings.Contains(string(stored), "second-password") { + t.Fatalf("EditUser stored the password in cleartext: %q", stored) + } + if !CheckUserPassword("edithashuser", "second-password") { + t.Fatal("the new password does not verify") + } + if CheckUserPassword("edithashuser", "first-password") { + t.Fatal("the old password still verifies") + } +} + +func TestLegacyPlaintextPasswordVerifiesAndIsUpgraded(t *testing.T) { + if err := vars.UsersDB.Put([]byte("legacyuser"), []byte("legacy-secret"), nil); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { vars.UsersDB.Delete([]byte("legacyuser"), nil) }) + + if !CheckUserPassword("legacyuser", "legacy-secret") { + t.Fatal("an account created before hashing stopped working") + } + + stored, _ := vars.UsersDB.Get([]byte("legacyuser"), nil) + if string(stored) == "legacy-secret" { + t.Fatal("a verified legacy password was not upgraded to a hash") + } + if !CheckUserPassword("legacyuser", "legacy-secret") { + t.Fatal("the password stopped verifying after the upgrade") + } +} diff --git a/application/backend/app/userdb/userdb.go b/application/backend/app/userdb/userdb.go index 6e7c862..db8c23c 100644 --- a/application/backend/app/userdb/userdb.go +++ b/application/backend/app/userdb/userdb.go @@ -1,10 +1,10 @@ package userdb import ( + "crypto/subtle" "encoding/json" "errors" "os" - "strings" "github.com/devforth/OnLogs/app/vars" ) @@ -19,8 +19,7 @@ func CreateUser(login string, password string) error { return errors.New("User is already exists") } - vars.UsersDB.Put([]byte(login), []byte(password), nil) - return nil + return vars.UsersDB.Put([]byte(login), []byte(HashPassword(password)), nil) } func GetUsers() []map[string]interface{} { @@ -45,8 +44,8 @@ func GetUsers() []map[string]interface{} { return users } -func EditUser(login string, password string) { - vars.UsersDB.Put([]byte(login), []byte(password), nil) +func EditUser(login string, password string) error { + return vars.UsersDB.Put([]byte(login), []byte(HashPassword(password)), nil) } func DeleteUser(login string, password string) error { @@ -60,11 +59,25 @@ func DeleteUser(login string, password string) error { } func CheckUserPassword(login string, gotPassword string) bool { - password, err := vars.UsersDB.Get([]byte(login), nil) - if err != nil || strings.Compare(string(password), gotPassword) != 0 { + // goleveldb returns ([]byte{}, nil) for a key stored with an empty value. + if login == "" || gotPassword == "" { + return false + } + + stored, err := vars.UsersDB.Get([]byte(login), nil) + if err != nil || len(stored) == 0 { return false } + if ok, wasHashed := verifyHash(string(stored), gotPassword); wasHashed { + return ok + } + + // Accounts predating hashing hold the password verbatim; verify, then upgrade. + if subtle.ConstantTimeCompare(stored, []byte(gotPassword)) != 1 { + return false + } + vars.UsersDB.Put([]byte(login), []byte(HashPassword(gotPassword)), nil) return true } diff --git a/application/backend/app/userdb/userdb_test.go b/application/backend/app/userdb/userdb_test.go index 9ae9ed3..fd9a317 100644 --- a/application/backend/app/userdb/userdb_test.go +++ b/application/backend/app/userdb/userdb_test.go @@ -35,14 +35,19 @@ func TestGetUsers(t *testing.T) { } } +// Rewritten: this used to assert the stored value was literally "sus?", which +// only held while passwords were kept in cleartext. func TestEditUser(t *testing.T) { CreateUser("testtest", "testtest") EditUser("testtest", "sus?") - pass, _ := vars.UsersDB.Get([]byte("testtest"), nil) - if string(pass) != "sus?" { + if !CheckUserPassword("testtest", "sus?") { t.Error("User wasn't edited") } + pass, _ := vars.UsersDB.Get([]byte("testtest"), nil) + if string(pass) == "sus?" { + t.Error("Password is stored in cleartext") + } } func TestDeleteUser(t *testing.T) { diff --git a/application/backend/app/util/inituser_test.go b/application/backend/app/util/inituser_test.go new file mode 100644 index 0000000..fb4367b --- /dev/null +++ b/application/backend/app/util/inituser_test.go @@ -0,0 +1,30 @@ +package util + +import ( + "os" + "testing" + + "github.com/devforth/OnLogs/app/vars" +) + +func TestCreateInitUserRefusesAnEmptyAdminPassword(t *testing.T) { + const probe = "admin-empty-pw-probe" + os.Setenv("ADMIN_USERNAME", probe) + os.Setenv("ADMIN_PASSWORD", "") + t.Cleanup(func() { + vars.UsersDB.Delete([]byte(probe), nil) + os.Setenv("ADMIN_USERNAME", "admin") + os.Setenv("ADMIN_PASSWORD", "") + }) + + CreateInitUser() + + exists, err := vars.UsersDB.Has([]byte(probe), nil) + if err != nil { + t.Fatal(err) + } + if exists { + stored, _ := vars.UsersDB.Get([]byte(probe), nil) + t.Fatalf("admin was created with an empty ADMIN_PASSWORD (stored value %q)", stored) + } +} diff --git a/application/backend/app/util/jwt_test.go b/application/backend/app/util/jwt_test.go new file mode 100644 index 0000000..3c10a80 --- /dev/null +++ b/application/backend/app/util/jwt_test.go @@ -0,0 +1,105 @@ +package util + +import ( + "net/http" + "os" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func requestWithCookie(value string) http.Request { + req, _ := http.NewRequest("GET", "", nil) + req.AddCookie(&http.Cookie{Name: "onlogs-cookie", Value: value}) + return *req +} + +func TestGetUserFromJWTRejectsUnsignedTokens(t *testing.T) { + os.Setenv("JWT_SECRET", "a-real-secret-value-for-the-test") + + token := jwt.NewWithClaims(jwt.SigningMethodNone, jwt.MapClaims{ + "user": "admin", + "authorized": true, + "exp": time.Now().Add(time.Hour).Unix(), + }) + unsigned, err := token.SignedString(jwt.UnsafeAllowNoneSignatureType) + if err != nil { + t.Fatal(err) + } + + user, err := GetUserFromJWT(requestWithCookie(unsigned)) + if err == nil { + t.Fatalf("alg=none token accepted as user %q", user) + } +} + +func TestGetUserFromJWTRejectsTokenWithoutExpiry(t *testing.T) { + secret := "a-real-secret-value-for-the-test" + os.Setenv("JWT_SECRET", secret) + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "user": "admin", + "authorized": true, + }) + signed, err := token.SignedString([]byte(secret)) + if err != nil { + t.Fatal(err) + } + + user, err := GetUserFromJWT(requestWithCookie(signed)) + if err == nil { + t.Fatalf("token without exp accepted as user %q", user) + } +} + +func TestGetUserFromJWTRejectsTokenWithoutUser(t *testing.T) { + secret := "a-real-secret-value-for-the-test" + os.Setenv("JWT_SECRET", secret) + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "authorized": true, + "exp": time.Now().Add(time.Hour).Unix(), + }) + signed, err := token.SignedString([]byte(secret)) + if err != nil { + t.Fatal(err) + } + + if user, err := GetUserFromJWT(requestWithCookie(signed)); err == nil { + t.Fatalf("token without a user claim accepted as %q", user) + } +} + +func TestGetUserFromJWTRejectsEverythingWhenTheSecretIsEmpty(t *testing.T) { + os.Setenv("JWT_SECRET", "") + t.Cleanup(func() { os.Setenv("JWT_SECRET", "1231efdZF") }) + + forged := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "user": "admin", + "authorized": true, + "exp": time.Now().Add(time.Hour).Unix(), + }) + signed, err := forged.SignedString([]byte("")) + if err != nil { + t.Fatal(err) + } + + if user, err := GetUserFromJWT(requestWithCookie(signed)); err == nil { + t.Fatalf("cookie forged with an empty key authenticated as %q", user) + } +} + +func TestGenerateJWTSecretIsNotPredictable(t *testing.T) { + seen := map[string]bool{} + for i := 0; i < 64; i++ { + s := GenerateJWTSecret() + if len(s) < 25 { + t.Fatalf("token too short: %q", s) + } + if seen[s] { + t.Fatalf("duplicate token generated: %q", s) + } + seen[s] = true + } +} diff --git a/application/backend/app/util/safename_test.go b/application/backend/app/util/safename_test.go new file mode 100644 index 0000000..6dd9aea --- /dev/null +++ b/application/backend/app/util/safename_test.go @@ -0,0 +1,86 @@ +package util + +import ( + "os" + "testing" +) + +func TestGetDBRefusesNamesThatLeaveTheTree(t *testing.T) { + t.Chdir(t.TempDir()) + + cases := [][3]string{ + {"../../../PWN", "c", "logs"}, + {"realhost", "../../../../PWN", "logs"}, + {"realhost", "c", "../../../PWN"}, + {"", "c", "logs"}, + {"realhost", "", "logs"}, + {".", "c", "logs"}, + {"..", "c", "logs"}, + {"a/b", "c", "logs"}, + } + + for _, c := range cases { + if db := GetDB(c[0], c[1], c[2]); db != nil { + t.Errorf("GetDB(%q, %q, %q) opened a database", c[0], c[1], c[2]) + } + } + + if _, err := os.Stat("PWN"); err == nil { + t.Fatal("GetDB created a directory outside leveldb/hosts") + } + if entries, err := os.ReadDir("."); err == nil { + for _, e := range entries { + if e.Name() != "leveldb" { + t.Errorf("unexpected entry created outside the tree: %s", e.Name()) + } + } + } +} + +func TestGetDBStillOpensLegitimateNames(t *testing.T) { + t.Chdir(t.TempDir()) + + db := GetDB("somehost", "somecontainer", "logs") + if db == nil { + t.Fatal("GetDB refused a legitimate host/container/dbType") + } + db.Close() + + if _, err := os.Stat("leveldb/hosts/somehost/containers/somecontainer/logs"); err != nil { + t.Fatalf("database was not created where expected: %v", err) + } +} + +func TestGetDirSizeRefusesNamesThatLeaveTheTree(t *testing.T) { + t.Chdir(t.TempDir()) + + if err := os.MkdirAll("leveldb/hosts/realhost/containers", 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll("outside", 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile("outside/big", make([]byte, 512*1024), 0o600); err != nil { + t.Fatal(err) + } + + if size := GetDirSize("realhost", "../../../../outside"); size != 0 { + t.Fatalf("GetDirSize measured a directory outside the tree: %v MiB", size) + } +} + +func TestGetDockerContainerIDRefusesNamesThatLeaveTheTree(t *testing.T) { + t.Chdir(t.TempDir()) + + if err := os.MkdirAll("leveldb/hosts", 0o700); err != nil { + t.Fatal(err) + } + + id := GetDockerContainerID("../..", "anything") + if id != "" { + t.Errorf("GetDockerContainerID accepted a traversing host: %q", id) + } + if _, err := os.Stat("containersMeta"); err == nil { + t.Fatal("GetDockerContainerID created a LevelDB outside leveldb/hosts") + } +} diff --git a/application/backend/app/util/util.go b/application/backend/app/util/util.go index 9de4e09..ef8528b 100644 --- a/application/backend/app/util/util.go +++ b/application/backend/app/util/util.go @@ -2,10 +2,10 @@ package util import ( "bytes" + "crypto/rand" "errors" "fmt" "io/fs" - "math/rand" "net/http" "os" "path/filepath" @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/devforth/OnLogs/app/userdb" "github.com/devforth/OnLogs/app/vars" "github.com/golang-jwt/jwt/v5" "github.com/syndtr/goleveldb/leveldb" @@ -45,13 +46,19 @@ func Contains(a string, list []string) bool { return false } -func CreateInitUser() { +func CreateInitUser() error { admin_username := os.Getenv("ADMIN_USERNAME") if admin_username == "" { admin_username = "admin" os.Setenv("ADMIN_USERNAME", admin_username) } - vars.UsersDB.Put([]byte(admin_username), []byte(os.Getenv("ADMIN_PASSWORD")), nil) + + admin_password := os.Getenv("ADMIN_PASSWORD") + if admin_password == "" { + return errors.New("ADMIN_PASSWORD is empty; refusing to create an administrator that would accept an empty password") + } + + return vars.UsersDB.Put([]byte(admin_username), []byte(userdb.HashPassword(admin_password)), nil) } func ReplacePrefixVariableForFrontend() { @@ -80,7 +87,17 @@ func CreateJWT(login string) string { return tokenString } +// Every path component must be a single, literal name; anything else escapes +// leveldb/hosts. +func IsSafeName(s string) bool { + return s != "" && s != "." && s != ".." && !strings.ContainsRune(s, 0) && s == filepath.Base(s) +} + func GetDB(host string, container string, dbType string) *leveldb.DB { + if !IsSafeName(host) || !IsSafeName(container) || !IsSafeName(dbType) { + return nil + } + vars.DBMutex.RLock() db := getExistingDB(host, container, dbType) vars.DBMutex.RUnlock() @@ -171,6 +188,10 @@ func GetHost() string { } func GetDirSize(host string, container string) float64 { + if !IsSafeName(host) || !IsSafeName(container) { + return 0 + } + var size int64 path := "leveldb/hosts/" + host + "/containers/" + container @@ -197,39 +218,46 @@ func GetUserFromJWT(req http.Request) (string, error) { return "", errors.New("401 - Unauthorized!") } + // An empty key verifies every HMAC token. + secret := os.Getenv("JWT_SECRET") + if secret == "" { + return "", errors.New("401 - Unauthorized!") + } + claims := jwt.MapClaims{} _, err := jwt.ParseWithClaims(c.Value, claims, func(token *jwt.Token) (interface{}, error) { - return []byte(os.Getenv("JWT_SECRET")), nil - }) + return []byte(secret), nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithExpirationRequired()) - if err != nil && strings.Compare(err.Error(), "Token is expired") != 0 { + if err != nil { return "", err } - if int64(int64(claims["exp"].(float64))) < time.Now().Unix() { + exp, ok := claims["exp"].(float64) + if !ok { + return "", errors.New("Token is expired") + } + if int64(exp) < time.Now().Unix() { return "", errors.New("Token is expired") } - return claims["user"].(string), nil + user, ok := claims["user"].(string) + if !ok || user == "" { + return "", errors.New("401 - Unauthorized!") + } + return user, nil } +// Mints agent ingestion tokens, so it must be cryptographically random. func GenerateJWTSecret() string { - tokenLen := 25 - - letterBytes := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-_." - b := make([]byte, tokenLen) - - s1 := rand.NewSource(time.Now().UnixNano()) - r1 := rand.New(s1) - for i := range b { - b[i] = letterBytes[r1.Int63()%int64(len(letterBytes))] - } - token := string(b) - - return token + return rand.Text() } func GetDockerContainerID(host string, container string) string { + if !IsSafeName(host) { + return "" + } + _, err := os.ReadDir("leveldb/hosts/" + host) if err != nil { return "" diff --git a/application/backend/app/util/util_test.go b/application/backend/app/util/util_test.go index 0c1ec20..37fe145 100644 --- a/application/backend/app/util/util_test.go +++ b/application/backend/app/util/util_test.go @@ -31,8 +31,16 @@ func TestContains(t *testing.T) { } } +// Rewritten: this used to assert that an admin is created with no password set. func TestCreateInitUser(t *testing.T) { - CreateInitUser() + os.Setenv("ADMIN_USERNAME", "admin") + os.Setenv("ADMIN_PASSWORD", "an-actual-admin-password") + t.Cleanup(func() { os.Setenv("ADMIN_PASSWORD", "") }) + + if err := CreateInitUser(); err != nil { + t.Fatalf("CreateInitUser: %v", err) + } + isExist, err := vars.UsersDB.Has([]byte("admin"), nil) if err != nil { t.Error(err.Error()) diff --git a/application/backend/main.go b/application/backend/main.go index add0599..6c30007 100644 --- a/application/backend/main.go +++ b/application/backend/main.go @@ -2,9 +2,12 @@ package main import ( "context" + "crypto/rand" + "encoding/hex" "fmt" "net/http" "os" + "time" "github.com/devforth/OnLogs/app/daemon" "github.com/devforth/OnLogs/app/db" @@ -16,18 +19,55 @@ import ( "github.com/joho/godotenv" ) +// The persisted key is only trusted when it is non-empty. +func ensureJWTSecret() error { + if os.Getenv("JWT_SECRET") != "" { + return nil + } + + if persisted, err := os.ReadFile("leveldb/JWT_secret"); err == nil && len(persisted) > 0 { + os.Setenv("JWT_SECRET", string(persisted)) + return nil + } + + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return fmt.Errorf("unable to generate a JWT secret: %w", err) + } + secret := hex.EncodeToString(buf) + + if err := os.MkdirAll("leveldb", 0700); err != nil { + return fmt.Errorf("unable to create leveldb directory for the JWT secret: %w", err) + } + if err := os.WriteFile("leveldb/JWT_secret", []byte(secret), 0600); err != nil { + return fmt.Errorf("unable to persist the generated JWT secret: %w", err) + } + + os.Setenv("JWT_SECRET", secret) + return nil +} + +// gorilla clears the connection deadlines after hijacking (server.go:251), so +// WriteTimeout does not reach the websocket at /api/v1/getLogsStream. +func newServer(port string, handler http.Handler) *http.Server { + return &http.Server{ + Addr: ":" + port, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 120 * time.Second, + IdleTimeout: 120 * time.Second, + } +} + func init_config() { if os.Getenv("PORT") == "" { os.Setenv("PORT", "2874") } - if os.Getenv("JWT_SECRET") == "" { - token, err := os.ReadFile("leveldb/JWT_secret") - if err != nil { - os.WriteFile("leveldb/JWT_secret", []byte(os.Getenv("JWT_SECRET")), 0700) - token, _ = os.ReadFile("leveldb/JWT_secret") - } - os.Setenv("JWT_SECRET", string(token)) + if err := ensureJWTSecret(); err != nil { + fmt.Println("FATAL:", err) + os.Exit(1) } if os.Getenv("DOCKER_HOST") == "" { @@ -45,6 +85,11 @@ func main() { godotenv.Load(".env") init_config() + if os.Getenv("JWT_SECRET") == "" { + fmt.Println("FATAL: JWT_SECRET is empty; refusing to start. Unset it to have one generated, or set a non-empty value.") + os.Exit(1) + } + cli, err := client.NewClientWithOpts( client.FromEnv, client.WithAPIVersionNegotiation(), @@ -78,7 +123,14 @@ func main() { go streamController.StreamLogs(bgContext) // go util.RunSpaceMonitoring() util.ReplacePrefixVariableForFrontend() - util.CreateInitUser() + if err := util.CreateInitUser(); err != nil { + if os.Getenv("DISABLE_AUTH") == "true" { + fmt.Println("WARNING:", err, "(DISABLE_AUTH=true, continuing without an administrator account)") + } else { + fmt.Println("FATAL:", err) + os.Exit(1) + } + } // Initialize the "Controller" with its dependencies routerCtrl := &routes.RouteController{ @@ -119,5 +171,5 @@ func main() { http.HandleFunc(pathPrefix+"/api/v1/updateUserSettings", routerCtrl.UpdateUserSettings) fmt.Println("Listening on port:", string(os.Getenv("PORT"))+"...") - fmt.Println("ONLOGS: ", http.ListenAndServe(":"+string(os.Getenv("PORT")), nil)) + fmt.Println("ONLOGS: ", newServer(os.Getenv("PORT"), nil).ListenAndServe()) } diff --git a/application/backend/main_test.go b/application/backend/main_test.go new file mode 100644 index 0000000..960617c --- /dev/null +++ b/application/backend/main_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "os" + "testing" +) + +func TestInitConfigGeneratesAJWTSecretWhenNoneIsConfigured(t *testing.T) { + t.Chdir(t.TempDir()) + t.Setenv("JWT_SECRET", "") + + init_config() + + secret := os.Getenv("JWT_SECRET") + if len(secret) < 32 { + t.Fatalf("JWT secret is %d bytes, want at least 32: %q", len(secret), secret) + } + + persisted, err := os.ReadFile("leveldb/JWT_secret") + if err != nil { + t.Fatalf("secret was not persisted: %v", err) + } + if string(persisted) != secret { + t.Fatalf("persisted secret %q does not match the active one %q", persisted, secret) + } + + // A second boot must reuse the persisted key. + t.Setenv("JWT_SECRET", "") + init_config() + if os.Getenv("JWT_SECRET") != secret { + t.Fatalf("second boot changed the secret: %q -> %q", secret, os.Getenv("JWT_SECRET")) + } +} + +func TestInitConfigReplacesAnEmptySecretFile(t *testing.T) { + t.Chdir(t.TempDir()) + t.Setenv("JWT_SECRET", "") + + if err := os.MkdirAll("leveldb", 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile("leveldb/JWT_secret", nil, 0o600); err != nil { + t.Fatal(err) + } + + init_config() + + if len(os.Getenv("JWT_SECRET")) < 32 { + t.Fatalf("empty secret file was accepted, JWT_SECRET=%q", os.Getenv("JWT_SECRET")) + } +} + +func TestInitConfigKeepsAnExplicitlyConfiguredSecret(t *testing.T) { + t.Chdir(t.TempDir()) + t.Setenv("JWT_SECRET", "configured-by-the-operator") + + init_config() + + if os.Getenv("JWT_SECRET") != "configured-by-the-operator" { + t.Fatalf("configured secret was overwritten: %q", os.Getenv("JWT_SECRET")) + } + if _, err := os.Stat("leveldb/JWT_secret"); err == nil { + t.Fatal("a configured secret must not be written to disk") + } +} + +func TestNewServerSetsAllTimeouts(t *testing.T) { + s := newServer("2874", nil) + + if s.ReadHeaderTimeout == 0 { + t.Error("ReadHeaderTimeout is unset, so a slowloris client can hold a connection open forever") + } + if s.ReadTimeout == 0 { + t.Error("ReadTimeout is unset") + } + if s.WriteTimeout == 0 { + t.Error("WriteTimeout is unset") + } + if s.IdleTimeout == 0 { + t.Error("IdleTimeout is unset") + } +} diff --git a/application/frontend/package-lock.json b/application/frontend/package-lock.json index 5e8b20c..d8507d1 100644 --- a/application/frontend/package-lock.json +++ b/application/frontend/package-lock.json @@ -11,7 +11,6 @@ "@vusion/webfonts-generator": "^0.8.0", "ansi-to-html": "^0.7.2", "chart.js": "^4.2.0", - "json-to-html": "^0.1.2", "svelte-chartjs": "^3.1.2", "svelte-loading-spinners": "^0.3.4", "svelte-routing": "^1.6.0" @@ -17861,11 +17860,6 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, - "node_modules/json-to-html": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/json-to-html/-/json-to-html-0.1.2.tgz", - "integrity": "sha512-gwezGNdnxPnp+7m5aVFq080KGjURyLqLAMmoRlkfnapQYluxQX18Hu+MOPYOtPaipYSB1bawQem5cmvRo/aAMA==" - }, "node_modules/json5": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", diff --git a/application/frontend/package.json b/application/frontend/package.json index 047e01c..5674561 100644 --- a/application/frontend/package.json +++ b/application/frontend/package.json @@ -6,13 +6,14 @@ "scripts": { "dev": "vite", "build": "vite build --base=/ONLOGS_PREFIX_ENV_VARIABLE_THAT_SHOULD_BE_REPLACED_ON_BACKEND_INITIALIZATION ", - "test": "vite build --base=/ONLOGS_PREFIX_ENV_VARIABLE_THAT_SHOULD_BE_REPLACED_ON_BACKEND_INITIALIZATION && node startDebug.js ", + "test": "node scripts/run-tests.mjs", "test:ansi": "node src/utils/ansi.test.mjs", "test:highlight": "node src/Views/Logs/highlight.test.mjs", "preview": "vite preview", "sb": "start-storybook -p 6006", "build-storybook": "build-storybook", - "buildfont": "node generate_font.js" + "buildfont": "node generate_font.js", + "debug": "vite build --base=/ONLOGS_PREFIX_ENV_VARIABLE_THAT_SHOULD_BE_REPLACED_ON_BACKEND_INITIALIZATION && node startDebug.js" }, "devDependencies": { "@babel/core": "^7.19.3", @@ -36,7 +37,6 @@ "@vusion/webfonts-generator": "^0.8.0", "ansi-to-html": "^0.7.2", "chart.js": "^4.2.0", - "json-to-html": "^0.1.2", "svelte-chartjs": "^3.1.2", "svelte-loading-spinners": "^0.3.4", "svelte-routing": "^1.6.0" diff --git a/application/frontend/scripts/run-tests.mjs b/application/frontend/scripts/run-tests.mjs new file mode 100644 index 0000000..6c3aa89 --- /dev/null +++ b/application/frontend/scripts/run-tests.mjs @@ -0,0 +1,20 @@ +import { globSync } from "node:fs"; +import { spawnSync } from "node:child_process"; + +const files = globSync("src/**/*.test.mjs").sort(); +if (files.length === 0) { + console.error("no test files matched src/**/*.test.mjs"); + process.exit(1); +} + +let failed = 0; +for (const file of files) { + const result = spawnSync(process.execPath, [file], { stdio: "inherit" }); + if (result.status !== 0) { + console.error(`FAIL ${file}`); + failed += 1; + } +} + +console.log(`\n${files.length - failed}/${files.length} test files passed`); +process.exit(failed === 0 ? 0 : 1); diff --git a/application/frontend/src/lib/LogsString/LogsString.svelte b/application/frontend/src/lib/LogsString/LogsString.svelte index bfdde8e..06b5b21 100644 --- a/application/frontend/src/lib/LogsString/LogsString.svelte +++ b/application/frontend/src/lib/LogsString/LogsString.svelte @@ -56,7 +56,7 @@ {#if !parsedStr}

{@html messageHtml}

{:else if $store.transformJson}

{@html toAnsiHtml(parsedStr.startText)}

-
{@html parsedStr.html}
+
{JSON.stringify(parsedStr.json, null, 2)}

{@html toAnsiHtml(parsedStr.endText)}

{:else}

{@html messageHtml} diff --git a/application/frontend/src/utils/functions.js b/application/frontend/src/utils/functions.js index 06b4a41..b1ab887 100644 --- a/application/frontend/src/utils/functions.js +++ b/application/frontend/src/utils/functions.js @@ -1,4 +1,3 @@ -import json2html from "json-to-html"; export const handleKeydown = (e, keyValue, cb) => { if (e.key === keyValue) { @@ -25,24 +24,19 @@ export const tryToParseLogString = (str) => { const beginningOfJson = str.search(/[{[]/); const endingOfJson = str.search(/[\]}](?![\s\S]*[\]}])/); - let html = ""; - let startText = ""; - let endText = ""; - - if (beginningOfJson !== -1 && endingOfJson !== -1 && endingOfJson > beginningOfJson) { - const jsonPart = str.slice(beginningOfJson, endingOfJson + 1); - startText = str.slice(0, beginningOfJson); - endText = str.slice(endingOfJson + 1); - - try { - const parsed = JSON.parse(jsonPart); - html = json2html(parsed, 2); - } catch (e) { } + if (beginningOfJson === -1 || endingOfJson === -1 || endingOfJson <= beginningOfJson) { + return null; } - if (html) { - return { startText, html, endText }; - } else return null; + try { + return { + startText: str.slice(0, beginningOfJson), + json: JSON.parse(str.slice(beginningOfJson, endingOfJson + 1)), + endText: str.slice(endingOfJson + 1), + }; + } catch (e) { + return null; + } }; export const copyText = function (ref, cb) { diff --git a/application/frontend/src/utils/functions.test.mjs b/application/frontend/src/utils/functions.test.mjs new file mode 100644 index 0000000..58bc491 --- /dev/null +++ b/application/frontend/src/utils/functions.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { tryToParseLogString } from "./functions.js"; + +const PAYLOAD = ''; + +function assertNoMarkupReachesHtmlSink(line, label) { + const parsed = tryToParseLogString(line); + assert.ok(parsed, `${label}: expected the JSON to be recognised`); + + // Whatever the component hands to {@html} must carry no live markup. + for (const [field, value] of Object.entries(parsed)) { + if (field === "json") continue; + assert.equal( + typeof value === "string" && value.includes(" Date: Fri, 7 Aug 2026 22:03:49 +0300 Subject: [PATCH 03/31] fix(logs): correct the log pagination loop conditions --- .../backend/app/containerdb/containerdb.go | 16 +- .../backend/app/containerdb/limit_test.go | 14 ++ .../Logs/LogsViewHeder/LogsViewHeder.svelte | 10 +- .../Logs/LogsViewHeder/LogsViewHeder.test.mjs | 69 ++++++ .../Views/Logs/NewLogsV2.pagination.test.mjs | 134 ++++++++++ .../frontend/src/Views/Logs/NewLogsV2.svelte | 232 ++++++++++-------- .../src/Views/Logs/NewLogsV2.test.mjs | 151 ++++++++++++ application/frontend/test/entry.js | 4 + application/frontend/test/harness.mjs | 158 ++++++++++++ .../test/stubs/IntersectionObserver.svelte | 34 +++ 10 files changed, 712 insertions(+), 110 deletions(-) create mode 100644 application/frontend/src/Views/Logs/LogsViewHeder/LogsViewHeder.test.mjs create mode 100644 application/frontend/src/Views/Logs/NewLogsV2.pagination.test.mjs create mode 100644 application/frontend/src/Views/Logs/NewLogsV2.test.mjs create mode 100644 application/frontend/test/entry.js create mode 100644 application/frontend/test/harness.mjs create mode 100644 application/frontend/test/stubs/IntersectionObserver.svelte diff --git a/application/backend/app/containerdb/containerdb.go b/application/backend/app/containerdb/containerdb.go index 907cc11..bdb6c60 100644 --- a/application/backend/app/containerdb/containerdb.go +++ b/application/backend/app/containerdb/containerdb.go @@ -211,6 +211,9 @@ const ( maxLogsPerRequest = 1000 ) +// Bounds one scan. A var so tests can exercise the cap without a million rows. +var maxScanIterations = 1000000 + var ( logCleanupMu sync.Mutex nextCleanup time.Time @@ -389,7 +392,12 @@ func GetLogs(getPrev bool, include bool, host string, container string, message iteration := 0 last_processed_key := "" normalizedMessage := normalizeForSearch(message, caseSensetivity) - for counter < limit && iteration < 1000000 { + hitScanCap := false + for counter < limit { + if iteration >= maxScanIterations { + hitScanCap = true + break + } iteration += 1 key := iter.Key() if len(key) == 0 { @@ -426,6 +434,12 @@ func GetLogs(getPrev bool, include bool, host string, container string, message last_processed_key = keyStr } + if hitScanCap { + // The scan was cut short, not exhausted. Reporting "more available" + // makes the client re-request the same page forever. + to_return["is_end"] = true + } + to_return["logs"] = logs to_return["last_processed_key"] = last_processed_key return to_return diff --git a/application/backend/app/containerdb/limit_test.go b/application/backend/app/containerdb/limit_test.go index 3a91f0c..5845ab4 100644 --- a/application/backend/app/containerdb/limit_test.go +++ b/application/backend/app/containerdb/limit_test.go @@ -73,3 +73,17 @@ func TestGetLogsClampsAnEnormousLimit(t *testing.T) { t.Fatalf("a caller-supplied limit of 2^30 returned %d rows; the response is unbounded", len(logs)) } } + +func TestGetLogsReportsEndWhenTheScanCapStopsIt(t *testing.T) { + seedLogs(t, "CapHost", "CapCont", 50) + + original := maxScanIterations + maxScanIterations = 10 + t.Cleanup(func() { maxScanIterations = original }) + + result := GetLogs(false, false, "CapHost", "CapCont", "no-such-text-anywhere", 30, "", false, nil) + + if result["is_end"] != true { + t.Fatalf("the scan stopped at the iteration cap but reported is_end=%v; the client re-requests the same page forever", result["is_end"]) + } +} diff --git a/application/frontend/src/Views/Logs/LogsViewHeder/LogsViewHeder.svelte b/application/frontend/src/Views/Logs/LogsViewHeder/LogsViewHeder.svelte index 90b1fbb..d2d8fa1 100644 --- a/application/frontend/src/Views/Logs/LogsViewHeder/LogsViewHeder.svelte +++ b/application/frontend/src/Views/Logs/LogsViewHeder/LogsViewHeder.svelte @@ -7,6 +7,7 @@ import DropDown from "../../../lib/DropDown/DropDown.svelte"; import { clickOutside } from "../../../lib/OutsideClicker/OutsideClicker.js"; import { hasSearchResetRequest } from "../shareLinkViewState.js"; + import { onDestroy } from "svelte"; let dropDownIsVisible = false; let isSearchVIsible = false; let lastSearchResetVersion = searchResetVersion; @@ -30,7 +31,10 @@ $: if (hasSearchResetRequest(lastSearchResetVersion, searchResetVersion)) { lastSearchResetVersion = searchResetVersion; isSearchVIsible = false; + clearTimeout(timer); } + + onDestroy(() => clearTimeout(timer));

@@ -85,10 +89,8 @@
{/if} { - searchText = e.target.value; - }} + value={searchText} + on:input={(e) => debounce(e.target.value)} placeholder="Search" /> diff --git a/application/frontend/src/Views/Logs/LogsViewHeder/LogsViewHeder.test.mjs b/application/frontend/src/Views/Logs/LogsViewHeder/LogsViewHeder.test.mjs new file mode 100644 index 0000000..a295e03 --- /dev/null +++ b/application/frontend/src/Views/Logs/LogsViewHeder/LogsViewHeder.test.mjs @@ -0,0 +1,69 @@ +// The search input must be debounced. LogsViewHeder defines a `debounce` helper +// and never wires it up, so every keystroke starts a full reload. +import assert from "node:assert/strict"; +import { + bundleComponent, + installDom, + settle, + importBundle, +} from "../../../../test/harness.mjs"; + +const DEBOUNCE_MS = 750; + +function type(window, input, value) { + input.value = value; + input.dispatchEvent(new window.Event("input", { bubbles: true })); +} + +async function run() { + const bundlePath = await bundleComponent("test/entry.js"); + const { window } = installDom({}); + const { LogsViewHeder } = await importBundle(bundlePath); + + const target = window.document.body; + const component = new LogsViewHeder({ + target, + props: { searchText: "", searchResetVersion: 0 }, + }); + + const observed = []; + component.$on("searchTextChanged", () => {}); + + const input = target.querySelector("input[type=text]"); + assert.ok(input, "the search input should be rendered"); + + // Type a word one character at a time, as a user would. + const word = "error"; + for (let i = 1; i <= word.length; i++) { + type(window, input, word.slice(0, i)); + await settle(2); + observed.push(component.searchText); + } + + console.log(` searchText after ${word.length} keystrokes: ${JSON.stringify(observed)}`); + + assert.deepEqual( + observed, + new Array(word.length).fill(""), + `each keystroke updated searchText immediately (${JSON.stringify(observed)}), ` + + `so every character starts a full reload` + ); + + // After the debounce window it must land exactly once, on the final value. + await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_MS + 250)); + assert.equal( + component.searchText, + word, + `searchText should be ${JSON.stringify(word)} after the debounce window, got ` + + JSON.stringify(component.searchText) + ); + + component.$destroy(); + console.log("LogsViewHeder debounce tests passed"); + process.exit(0); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/application/frontend/src/Views/Logs/NewLogsV2.pagination.test.mjs b/application/frontend/src/Views/Logs/NewLogsV2.pagination.test.mjs new file mode 100644 index 0000000..ec1e0fd --- /dev/null +++ b/application/frontend/src/Views/Logs/NewLogsV2.pagination.test.mjs @@ -0,0 +1,134 @@ +// Drives every scroll interceptor in the log view and asserts the invariants +// that the duplication bug broke: the rendered window stays bounded, and no log +// line is ever on screen twice. +// +// fetchedLogs' pagination loop had an inverted comparison and so had never run; +// this exercises the newly-live path rather than shipping it unverified. +import assert from "node:assert/strict"; +import { + bundleComponent, + installDom, + jsonResponse, + settle, + importBundle, +} from "../../../test/harness.mjs"; + +const LIMIT = 60; + +let nextIndex = 0; + +// Distinct, strictly decreasing keys, as the backend emits them. +function makePage(count) { + return Array.from({ length: count }, () => { + const idx = nextIndex++; + const nanos = String(999999999 - idx * 1000).padStart(9, "0"); + return [`2026-02-10T12:44:03.${nanos}Z`, `line-${idx}`]; + }); +} + +function limitFromUrl(url) { + const match = /[?&]limit=(\d+)/.exec(String(url)); + return match ? Number(match[1]) : LIMIT; +} + +function stubFetch(counter) { + return async (url) => { + const target = String(url); + if (target.includes("getLogs?")) { + counter.getLogs += 1; + // Honour the requested limit, as a real backend does. + const rows = makePage(limitFromUrl(target)); + return jsonResponse({ + logs: rows, + last_processed_key: rows.at(-1)[0], + is_end: false, + }); + } + // Nothing newer than what is already on screen. + return jsonResponse({ logs: [], last_processed_key: "", is_end: true }); + }; +} + +function renderedMessages(target) { + return [...target.querySelectorAll(".message p")] + .map((el) => el.textContent.trim()) + .filter((text) => text.startsWith("line-")); +} + +function assertInvariants(target, label) { + const messages = renderedMessages(target); + const unique = new Set(messages); + + assert.equal( + unique.size, + messages.length, + `${label}: the same log line is on screen more than once ` + + `(${messages.length} rows, ${unique.size} distinct)` + ); + assert.ok( + messages.length <= 3 * LIMIT, + `${label}: the rendered window grew past 3x the page size ` + + `(${messages.length} rows); it should slide, not accumulate` + ); + return messages; +} + +async function run() { + const counter = { getLogs: 0 }; + const { window } = installDom({ fetchImpl: stubFetch(counter) }); + const bundle = await importBundle(await bundleComponent("test/entry.js")); + + bundle.lastChosenHost.set("testhost"); + bundle.lastChosenService.set("testservice"); + bundle.isPending.set(false); + + const target = window.document.body; + const component = new bundle.NewLogsV2({ target }); + + await settle(40); + // The view only arms its interceptors once initialScroll flips, on a 1s timer. + await new Promise((resolve) => setTimeout(resolve, 1400)); + await settle(10); + + const initial = assertInvariants(target, "initial load"); + console.log(` initial load: ${initial.length} rows, ${counter.getLogs} request(s)`); + assert.equal(initial.length, 3 * LIMIT, "expected three pages on screen"); + + // Fire every interceptor the view registered, one at a time. + const observers = globalThis.__onlogsObservers || []; + assert.ok(observers.length > 0, "no interceptors were registered"); + + let triggered = 0; + for (let i = 0; i < observers.length; i++) { + const before = counter.getLogs; + observers[i].set(true); + await settle(10); + observers[i].set(false); + await settle(5); + + if (counter.getLogs !== before) { + triggered += 1; + const rows = assertInvariants(target, `after interceptor ${i}`); + console.log( + ` interceptor ${i}: +${counter.getLogs - before} request(s), ${rows.length} rows` + ); + } + } + + assert.ok( + triggered > 0, + "no interceptor triggered a fetch, so upward pagination was not exercised" + ); + + const finalRows = assertInvariants(target, "final"); + console.log(` after ${triggered} interceptor firing(s): ${finalRows.length} rows`); + + component.$destroy(); + console.log("NewLogsV2 pagination tests passed"); + process.exit(0); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/application/frontend/src/Views/Logs/NewLogsV2.svelte b/application/frontend/src/Views/Logs/NewLogsV2.svelte index 5310a0f..4ae0dd7 100644 --- a/application/frontend/src/Views/Logs/NewLogsV2.svelte +++ b/application/frontend/src/Views/Logs/NewLogsV2.svelte @@ -81,7 +81,7 @@ let interceptorsWait = false; let autoscroll = false; let div; - let getFullLogsSetIsTrottle = false; + let logsLoadGeneration = 0; let pauseWS = false; let newLogsAmount = 1; let controller = null; @@ -144,45 +144,64 @@ } async function getFullLogsSet() { - if (!getFullLogsSetIsTrottle && $lastChosenService) { - const initialService = $lastChosenService; - pauseWS = true; - let total_logs_amount = 0; - let last_key = ""; - let is_all_logs_processed = false; - while (total_logs_amount < limit && !is_all_logs_processed) { - isSearching.set(true); - let data = await api.getLogs({ - containerName: $lastChosenService, - hostName: $lastChosenHost, - limit: limit * 3, - search: searchText, - caseSens: !$store.caseInSensitive, - status: $chosenStatus, - startWith: last_key, - }) - last_key = data.last_processed_key; - is_all_logs_processed = data.is_end; - total_logs_amount += data.logs.length; + if (!$lastChosenService) { + return; + } - if (initialService === $lastChosenService) { - setLastLogTime(data.logs?.at(0)?.at(0)); - allLogs = [...allLogs, ...data.logs.reverse()]; - let allLogsCopy = [...allLogs]; + const initialService = $lastChosenService; + const generation = ++logsLoadGeneration; + pauseWS = true; - newLogs = allLogsCopy.splice(0, limit); + let acc = []; + let total_logs_amount = 0; + let last_key = ""; + let is_all_logs_processed = false; - visibleLogs = allLogsCopy.splice(0, limit); - previousLogs = allLogsCopy.splice(0, limit); - } + while (total_logs_amount < limit && !is_all_logs_processed) { + isSearching.set(true); + const data = await api.getLogs({ + containerName: $lastChosenService, + hostName: $lastChosenHost, + limit: limit * 3, + search: searchText, + caseSens: !$store.caseInSensitive, + status: $chosenStatus, + startWith: last_key, + }); + + // A newer load, or a service switch, has superseded this one. + if (generation !== logsLoadGeneration || initialService !== $lastChosenService) { + return; } - isSearching.set(false); - isPending.set(false); - autoscroll = true; - pauseWS = false; - logsFromWS = []; + last_key = data.last_processed_key; + is_all_logs_processed = data.is_end; + total_logs_amount += data.logs.length; + + if (!data.logs.length) { + break; + } + + setLastLogTime(data.logs?.at(0)?.at(0)); + acc = [...acc, ...data.logs.reverse()]; + } + + if (generation !== logsLoadGeneration || initialService !== $lastChosenService) { + return; } + + allLogs = acc; + const allLogsCopy = [...allLogs]; + newLogs = allLogsCopy.splice(0, limit); + visibleLogs = allLogsCopy.splice(0, limit); + previousLogs = allLogsCopy.splice(0, limit); + + isSearching.set(false); + isPending.set(false); + autoscroll = true; + pauseWS = false; + + logsFromWS = []; } async function checkIfHashIsInUrl() { @@ -509,83 +528,80 @@ initialScroll = val; } const fetchedLogs = async (doNotScroll, customStartWith) => { - if (!$isFeatching) { - stopLogsUnfetch = false; - controller = new AbortController(); - signal = controller.signal; - // if (mouseDownBlockFetch) { - // return; - // } - const initialService = $lastChosenService; - if (scrollDirection === "up") { - isFeatching.set(true); + if ($isFeatching || scrollDirection !== "up") { + return; + } - try { - let total_logs_amount = 0; - let total_logs = []; - let is_all_logs_processed = false; - let last_key = customStartWith ? customStartWith : customStartWith === 0 ? "" : allLogs.at(0)?.at(0); - while (limit < total_logs_amount && !is_all_logs_processed) { - isSearching.set(true); - const data = (await getLogs({ - containerName: $lastChosenService, - search: searchText, - limit, - status: $chosenStatus, - caseSens: !$store.caseInSensitive, - startWith: last_key, - hostName: $lastChosenHost, - signal, - })).logs.reverse(); - total_logs = [...total_logs, ...data]; - total_logs_amount += data.length; - is_all_logs_processed = data.is_end; - last_key = data.last_processed_key; - - if (initialService === $lastChosenService) { - if (data.length) { - let numberOfNewLogs = data.length; - - const logsToPrevious = visibleLogs.splice( - visibleLogs.length - numberOfNewLogs, - numberOfNewLogs - ); - - const logsToVisible = newLogs.splice( - newLogs.length - numberOfNewLogs, - numberOfNewLogs - ); - - previousLogs.splice( - previousLogs.length - numberOfNewLogs, - numberOfNewLogs - ); - newLogs = [...data, ...newLogs]; - visibleLogs = [...logsToVisible, ...visibleLogs]; - previousLogs = [...logsToPrevious, ...previousLogs]; - previousLogs.length = limit; - - allLogs = [...newLogs, ...visibleLogs, ...previousLogs]; - } - if (data.length === limit) { - setTimeout(() => { - if (!doNotScroll) { - scrollToNewLogsEnd(".newLogsEnd"); - } - }, 50); - } + stopLogsUnfetch = false; + controller = new AbortController(); + signal = controller.signal; + const initialService = $lastChosenService; + isFeatching.set(true); - } - isSearching.set(false); + try { + let total_logs_amount = 0; + let total_logs = []; + let is_all_logs_processed = false; + let last_key = customStartWith + ? customStartWith + : customStartWith === 0 + ? "" + : allLogs.at(0)?.at(0); + + while (total_logs_amount < limit && !is_all_logs_processed) { + isSearching.set(true); + const response = await getLogs({ + containerName: $lastChosenService, + search: searchText, + limit, + status: $chosenStatus, + caseSens: !$store.caseInSensitive, + startWith: last_key, + hostName: $lastChosenHost, + signal, + }); + + const data = response.logs.reverse(); + total_logs = [...total_logs, ...data]; + total_logs_amount += data.length; + is_all_logs_processed = response.is_end; + last_key = response.last_processed_key; + + if (initialService !== $lastChosenService || !data.length) { + break; } - lastFetchActionIsFetch = true; - return total_logs; - } catch (e) { - console.log(e); + + const numberOfNewLogs = data.length; + const logsToPrevious = visibleLogs.splice( + visibleLogs.length - numberOfNewLogs, + numberOfNewLogs + ); + const logsToVisible = newLogs.splice( + newLogs.length - numberOfNewLogs, + numberOfNewLogs + ); + previousLogs.splice(previousLogs.length - numberOfNewLogs, numberOfNewLogs); + + newLogs = [...data, ...newLogs]; + visibleLogs = [...logsToVisible, ...visibleLogs]; + previousLogs = [...logsToPrevious, ...previousLogs].slice(0, limit); + allLogs = [...newLogs, ...visibleLogs, ...previousLogs]; + + if (data.length === limit && !doNotScroll) { + setTimeout(() => { + scrollToNewLogsEnd(".newLogsEnd"); + }, 50); } } + + lastFetchActionIsFetch = true; + return total_logs; + } catch (e) { + console.log(e); + } finally { + isSearching.set(false); + isFeatching.set(false); } - isFeatching.set(false); }; const fetchedTopLogs = async (customStartWith) => { @@ -623,6 +639,9 @@ is_all_logs_processed = data.is_end; last_key = data.last_processed_key; total_received_logs_count += data.logs.length; + if (!data.logs.length) { + break; + } total_logs = [...total_logs, ...data.logs.reverse()]; } isSearching.set(false); @@ -669,6 +688,9 @@ total_received_logs_count += data.logs.length; is_all_logs_processed = data.is_end; last_key = data.last_processed_key; + if (!data.logs.length) { + break; + } total_logs = [...total_logs, ...data.logs]; } isSearching.set(false); diff --git a/application/frontend/src/Views/Logs/NewLogsV2.test.mjs b/application/frontend/src/Views/Logs/NewLogsV2.test.mjs new file mode 100644 index 0000000..4f61f91 --- /dev/null +++ b/application/frontend/src/Views/Logs/NewLogsV2.test.mjs @@ -0,0 +1,151 @@ +// The reported duplication bug, asserted against the real component. +// +// NewLogsV2 is conditionally rendered, so Svelte runs all three of its $: blocks +// in the same flush at init. Each one calls getFullLogsSet(), which appends to +// allLogs instead of owning it, so one page of rows renders three times. +import assert from "node:assert/strict"; +import { + bundleComponent, + installDom, + jsonResponse, + settle, + importBundle, +} from "../../../test/harness.mjs"; + +const PAGE = [ + ["2026-02-10T12:44:03.560000000Z", "ONLOGS: Container listening started!"], + ["2026-02-10T12:44:07.370000000Z", "a"], + ["2026-02-10T12:44:07.380000000Z", "b"], +]; + +function stubFetch(counter) { + return async (url) => { + const target = String(url); + if (target.includes("getLogs?")) { + counter.getLogs += 1; + return jsonResponse({ + // The component reverses this array in place, so hand out a fresh copy. + logs: PAGE.map((row) => [...row]), + last_processed_key: PAGE[0][0], + is_end: true, + }); + } + if (target.includes("getPrevLogs?") || target.includes("getLogWithPrev?")) { + return jsonResponse({ logs: [], last_processed_key: "", is_end: true }); + } + return jsonResponse({ error: null }); + }; +} + +// A run of non-matching keys makes the backend return zero rows with +// is_end:false and last_processed_key:"", which resets the cursor to the newest +// row. The client must stop, not re-request the same empty page forever. +const RUNAWAY_CAP = 40; + +function stubEmptyPageFetch(counter) { + return async (url) => { + const target = String(url); + if (target.includes("getLogs?")) { + counter.getLogs += 1; + if (counter.getLogs > RUNAWAY_CAP) { + // Break the loop so the test reports a count instead of hanging. + return jsonResponse({ logs: [], last_processed_key: "", is_end: true }); + } + return jsonResponse({ logs: [], last_processed_key: "", is_end: false }); + } + if (target.includes("getPrevLogs?") || target.includes("getLogWithPrev?")) { + return jsonResponse({ logs: [], last_processed_key: "", is_end: true }); + } + return jsonResponse({ error: null }); + }; +} + +function renderedRowCount(target) { + return target.querySelectorAll(".chosenString").length; +} + +function renderedTimestamps(target) { + return [...target.querySelectorAll(".time p")] + .map((el) => el.textContent.trim()) + .filter(Boolean); +} + +async function mount(bundle, fetchImpl, counter) { + const { window } = installDom({ fetchImpl }); + const { NewLogsV2, lastChosenHost, lastChosenService, isPending } = bundle; + + // A remount with the stores already populated — Logs -> Stats -> Logs. This is + // the trigger from the ticket, and it fires all three reactive blocks at once. + lastChosenHost.set("testhost"); + lastChosenService.set("testservice"); + isPending.set(false); + + const target = window.document.body; + const component = new NewLogsV2({ target }); + await settle(); + return { component, target, counter }; +} + +async function run() { + const bundlePath = await bundleComponent("test/entry.js"); + + const counter = { getLogs: 0 }; + installDom({ fetchImpl: stubFetch(counter) }); + const bundle = await importBundle(bundlePath); + + const { component, target } = await mount(bundle, stubFetch(counter), counter); + + const rows = renderedRowCount(target); + const timestamps = renderedTimestamps(target); + + console.log(` backend returned ${PAGE.length} rows in ${counter.getLogs} request(s)`); + console.log(` component rendered ${rows} rows`); + for (const t of timestamps) console.log(` ${t}`); + + assert.equal( + rows, + PAGE.length, + `the backend returned ${PAGE.length} rows but the view rendered ${rows} ` + + `(${(rows / PAGE.length).toFixed(1)}x duplication)` + ); + + // Every rendered timestamp must be distinct: a duplicate is the same stored + // row drawn twice, which is exactly the screenshot on the ticket. + assert.equal( + new Set(timestamps).size, + timestamps.length, + `duplicate rows on screen: ${JSON.stringify(timestamps)}` + ); + + component.$destroy(); + + // --- an empty page must end the load, not restart it --- + const emptyCounter = { getLogs: 0 }; + const empty = await mount( + bundle, + stubEmptyPageFetch(emptyCounter), + emptyCounter + ); + + console.log(` empty-page load issued ${emptyCounter.getLogs} request(s)`); + assert.ok( + emptyCounter.getLogs < RUNAWAY_CAP, + `an empty page with is_end:false re-requested the same page ` + + `${emptyCounter.getLogs} times; the cursor resets to the newest row and ` + + `the client never terminates` + ); + assert.equal( + renderedRowCount(empty.target), + 0, + "an empty result set should render no rows" + ); + empty.component.$destroy(); + + console.log("NewLogsV2 duplication tests passed"); + process.exit(0); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/application/frontend/test/entry.js b/application/frontend/test/entry.js new file mode 100644 index 0000000..26189a1 --- /dev/null +++ b/application/frontend/test/entry.js @@ -0,0 +1,4 @@ +export { default as NewLogsV2 } from "../src/Views/Logs/NewLogsV2.svelte"; +export { default as LogsViewHeder } from "../src/Views/Logs/LogsViewHeder/LogsViewHeder.svelte"; +export * from "../src/Stores/stores.js"; +export { tick } from "svelte"; diff --git a/application/frontend/test/harness.mjs b/application/frontend/test/harness.mjs new file mode 100644 index 0000000..a030e3a --- /dev/null +++ b/application/frontend/test/harness.mjs @@ -0,0 +1,158 @@ +// Bundles a Svelte component with the repo's own Svelte and mounts it under +// jsdom, so component behaviour can be asserted headlessly. +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import esbuild from "esbuild"; +import * as svelte from "svelte/compiler"; +import { JSDOM } from "jsdom"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); + +const sveltePlugin = { + name: "svelte", + setup(build) { + build.onLoad({ filter: /\.svelte$/ }, (args) => { + const source = readFileSync(args.path, "utf8"); + const { js } = svelte.compile(source, { + filename: args.path, + generate: "dom", + css: false, + dev: false, + // Test-only: exposes props on the instance so assertions can read them. + accessors: true, + }); + return { contents: js.code, warnings: [] }; + }); + }, +}; + +// esbuild 0.15 has no "empty" loader; assets are irrelevant to behaviour tests. +const emptyAssetPlugin = { + name: "empty-assets", + setup(build) { + build.onResolve({ filter: /\.(css|scss|svg|png|woff2?|ttf|eot)$/ }, (args) => ({ + path: args.path, + namespace: "empty-asset", + })); + build.onLoad({ filter: /.*/, namespace: "empty-asset" }, () => ({ + contents: "export default {};", + loader: "js", + })); + }, +}; + +const aliasPlugin = { + name: "alias", + setup(build) { + build.onResolve({ filter: /^svelte-intersection-observer$/ }, () => ({ + path: join(ROOT, "test/stubs/IntersectionObserver.svelte"), + })); + }, +}; + +export async function bundleComponent(entryRelativePath) { + const outdir = mkdtempSync(join(tmpdir(), "onlogs-harness-")); + const outfile = join(outdir, "bundle.mjs"); + + await esbuild.build({ + entryPoints: [join(ROOT, entryRelativePath)], + outfile, + bundle: true, + format: "esm", + platform: "browser", + mainFields: ["svelte", "browser", "module", "main"], + conditions: ["svelte", "browser", "import"], + logLevel: "silent", + plugins: [aliasPlugin, sveltePlugin, emptyAssetPlugin], + }); + + return outfile; +} + +class FakeWebSocket { + constructor(url) { + this.url = url; + this.readyState = 1; + FakeWebSocket.instances.push(this); + } + send() {} + close() { + this.readyState = 3; + } +} +FakeWebSocket.instances = []; + +export function installDom({ fetchImpl } = {}) { + const dom = new JSDOM("", { + url: "http://onlogs.test/", + pretendToBeVisual: true, + }); + + const { window } = dom; + const define = (name, value) => + Object.defineProperty(globalThis, name, { + value, + writable: true, + configurable: true, + }); + + globalThis.window = window; + globalThis.document = window.document; + define("navigator", window.navigator); + define("location", window.location); + globalThis.HTMLElement = window.HTMLElement; + globalThis.Element = window.Element; + globalThis.Node = window.Node; + globalThis.Event = window.Event; + globalThis.CustomEvent = window.CustomEvent; + globalThis.getComputedStyle = window.getComputedStyle.bind(window); + globalThis.requestAnimationFrame = + window.requestAnimationFrame || ((cb) => setTimeout(() => cb(Date.now()), 16)); + globalThis.cancelAnimationFrame = window.cancelAnimationFrame || clearTimeout; + + // jsdom implements no scrolling at all. + window.Element.prototype.scrollTo = function () {}; + window.Element.prototype.scrollIntoView = function () {}; + window.scrollTo = () => {}; + + // jsdom implements neither of these. + globalThis.IntersectionObserver = window.IntersectionObserver = class { + constructor(cb) { + this.cb = cb; + } + observe() {} + unobserve() {} + disconnect() {} + }; + FakeWebSocket.instances = []; + globalThis.WebSocket = window.WebSocket = FakeWebSocket; + + if (fetchImpl) { + globalThis.fetch = fetchImpl; + window.fetch = fetchImpl; + } + + return { dom, window, sockets: FakeWebSocket.instances }; +} + +export function jsonResponse(payload, status = 200) { + return { + status, + ok: status >= 200 && status < 300, + json: async () => payload, + }; +} + +// Lets every queued microtask and 0ms timer drain, which is what a Svelte +// reactive flush plus its awaited fetches need. +export async function settle(rounds = 30) { + for (let i = 0; i < rounds; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +export async function importBundle(outfile) { + return import(pathToFileURL(outfile).href); +} diff --git a/application/frontend/test/stubs/IntersectionObserver.svelte b/application/frontend/test/stubs/IntersectionObserver.svelte new file mode 100644 index 0000000..b8cb480 --- /dev/null +++ b/application/frontend/test/stubs/IntersectionObserver.svelte @@ -0,0 +1,34 @@ + + + From cda6d8e0afc550cab494f75057593a07cf6778ff Mon Sep 17 00:00:00 2001 From: Roman Malenko Date: Fri, 7 Aug 2026 22:03:49 +0300 Subject: [PATCH 04/31] fix(concurrency): guard shared state behind mutex-protected accessors --- .github/workflows/go.yml | 21 +++ application/backend/app/agent/agent.go | 52 +++++--- application/backend/app/agent/resend_test.go | 75 +++++++++++ .../backend/app/containerdb/containerdb.go | 61 +++++---- .../backend/app/containerdb/mutex_test.go | 126 ++++++++++++++++++ .../backend/app/containerdb/reset_test.go | 91 +++++++++++++ application/backend/app/daemon/daemon.go | 21 +-- application/backend/app/routes/routes.go | 85 ++++++------ .../backend/app/routes/security_test.go | 56 +++++++- .../backend/app/statistics/race_test.go | 78 +++++++++++ .../backend/app/statistics/registry.go | 67 ++++++++++ .../backend/app/statistics/statistics.go | 33 +++-- .../backend/app/statistics/statistics_test.go | 14 +- application/backend/app/streamer/streamer.go | 70 +++------- .../backend/app/streamer/streamer_test.go | 23 +++- .../backend/app/util/agentmode_test.go | 46 +++++++ application/backend/app/util/util.go | 75 +++++++++-- application/backend/app/vars/connections.go | 61 +++++++++ .../backend/app/vars/connections_test.go | 108 +++++++++++++++ application/backend/app/vars/state.go | 90 +++++++++++++ application/backend/app/vars/state_test.go | 62 +++++++++ application/backend/app/vars/vars.go | 5 +- application/backend/main.go | 2 +- 23 files changed, 1111 insertions(+), 211 deletions(-) create mode 100644 application/backend/app/agent/resend_test.go create mode 100644 application/backend/app/containerdb/mutex_test.go create mode 100644 application/backend/app/containerdb/reset_test.go create mode 100644 application/backend/app/statistics/race_test.go create mode 100644 application/backend/app/statistics/registry.go create mode 100644 application/backend/app/util/agentmode_test.go create mode 100644 application/backend/app/vars/connections.go create mode 100644 application/backend/app/vars/connections_test.go create mode 100644 application/backend/app/vars/state.go create mode 100644 application/backend/app/vars/state_test.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index bf0b6ec..deac615 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -39,12 +39,33 @@ jobs: color: red namedLogo: checkmarx + - name: Vet + run: | + cd application/backend + go vet ./... + - name: Test run: | cd application/backend go test ./... -coverprofile cover.out go tool cover -func cover.out > covered.txt + - name: Test with the race detector + run: | + cd application/backend + go test -race ./... + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22.x + + - name: Frontend tests + run: | + cd application/frontend + npm ci + npm test + - name: Get coverage run: | cd application/backend diff --git a/application/backend/app/agent/agent.go b/application/backend/app/agent/agent.go index fe2e2fe..b9fba0a 100644 --- a/application/backend/app/agent/agent.go +++ b/application/backend/app/agent/agent.go @@ -8,7 +8,6 @@ import ( "os" "github.com/devforth/OnLogs/app/util" - "github.com/devforth/OnLogs/app/vars" ) func SendInitRequest(containers []string) { @@ -38,8 +37,13 @@ func SendLogMessage(token string, container string, message_item []string) bool "Container": container, }) resp, err := http.Post(os.Getenv("HOST")+"/api/v1/addLogLine", "application/json", bytes.NewBuffer(postBody)) + if err == nil { + defer resp.Body.Close() + } if err != nil || resp.StatusCode != 200 { - vars.BrokenLogs_DBs[container].Put([]byte(message_item[0]), []byte(message_item[1]), nil) + if buffer := util.GetDB(util.GetHost(), container, "brokenlogs"); buffer != nil { + buffer.Put([]byte(message_item[0]), []byte(message_item[1]), nil) + } return false } return true @@ -49,31 +53,31 @@ func TryResend() { token := os.Getenv("ONLOGS_TOKEN") containers, _ := os.ReadDir("leveldb/hosts/" + util.GetHost() + "/containers/") for _, container := range containers { - tmpDB := vars.BrokenLogs_DBs[container.Name()] - if tmpDB == nil { - tmpDB = util.GetDB(util.GetHost(), container.Name(), "/brokenLogs") - defer tmpDB.Close() + if !resendContainerBuffer(token, container.Name()) { + return } + } +} - iter := tmpDB.NewIterator(nil, nil) - defer iter.Release() - iter.First() - if iter.Value() == nil { - continue - } +// Returns false when the master is still unreachable, so the caller stops. +func resendContainerBuffer(token string, container string) bool { + buffer := util.GetDB(util.GetHost(), container, "brokenlogs") + if buffer == nil { + return true + } - if !SendLogMessage(token, container.Name(), []string{string(iter.Key()), string(iter.Value())}) { - return - } - tmpDB.Delete(iter.Key(), nil) + iter := buffer.NewIterator(nil, nil) + defer iter.Release() - for iter.Next() { - if !SendLogMessage(token, container.Name(), []string{string(iter.Key()), string(iter.Value())}) { - return - } - tmpDB.Delete(iter.Key(), nil) + for iter.Next() { + key := append([]byte{}, iter.Key()...) + value := append([]byte{}, iter.Value()...) + if !SendLogMessage(token, container, []string{string(key), string(value)}) { + return false } + buffer.Delete(key, nil) } + return true } func SendUpdate(containers []string) { @@ -95,7 +99,11 @@ func AskForDelete() { }) responseBody := bytes.NewBuffer(postBody) - resp, _ := http.Post(os.Getenv("HOST")+"/api/v1/askForDelete", "application/json", responseBody) + resp, err := http.Post(os.Getenv("HOST")+"/api/v1/askForDelete", "application/json", responseBody) + if err != nil { + return + } + defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if string(body) != "" { diff --git a/application/backend/app/agent/resend_test.go b/application/backend/app/agent/resend_test.go new file mode 100644 index 0000000..ded1271 --- /dev/null +++ b/application/backend/app/agent/resend_test.go @@ -0,0 +1,75 @@ +package agent + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/devforth/OnLogs/app/util" +) + +func TestSendLogMessageBuffersInsteadOfPanicking(t *testing.T) { + t.Chdir(t.TempDir()) + os.Setenv("HOST", "http://127.0.0.1:1") + + container := "bufferedcontainer" + panicked := make(chan interface{}, 1) + go func() { + defer func() { panicked <- recover() }() + SendLogMessage("token", container, []string{"2026-02-10T12:56:09.230421754Z", "a line"}) + }() + if r := <-panicked; r != nil { + t.Fatalf("a failed delivery panicked instead of buffering: %v", r) + } + + buffer := util.GetDB(util.GetHost(), container, "brokenlogs") + if buffer == nil { + t.Fatal("no broken-logs buffer was opened") + } + value, err := buffer.Get([]byte("2026-02-10T12:56:09.230421754Z"), nil) + if err != nil { + t.Fatalf("the undelivered line was not buffered: %v", err) + } + if string(value) != "a line" { + t.Fatalf("buffered the wrong value: %q", value) + } +} + +func TestTryResendDeliversAndDrainsTheBuffer(t *testing.T) { + t.Chdir(t.TempDir()) + container := "resendcontainer" + + if err := os.MkdirAll("leveldb/hosts/"+util.GetHost()+"/containers/"+container, 0o700); err != nil { + t.Fatal(err) + } + + buffer := util.GetDB(util.GetHost(), container, "brokenlogs") + if buffer == nil { + t.Fatal("could not open the broken-logs buffer") + } + for _, ts := range []string{"2026-02-10T12:56:09.000000001Z", "2026-02-10T12:56:09.000000002Z"} { + if err := buffer.Put([]byte(ts), []byte("buffered "+ts), nil); err != nil { + t.Fatal(err) + } + } + + delivered := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + delivered++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + os.Setenv("HOST", server.URL) + + TryResend() + + if delivered != 2 { + t.Fatalf("expected both buffered lines to be resent, got %d", delivered) + } + iter := buffer.NewIterator(nil, nil) + defer iter.Release() + if iter.Next() { + t.Fatalf("the buffer still holds %q after a successful resend", iter.Key()) + } +} diff --git a/application/backend/app/containerdb/containerdb.go b/application/backend/app/containerdb/containerdb.go index bdb6c60..9bc7f21 100644 --- a/application/backend/app/containerdb/containerdb.go +++ b/application/backend/app/containerdb/containerdb.go @@ -255,6 +255,20 @@ func MaybeScheduleCleanup(host string, container string) { }() } +func newStatCounter() map[string]uint64 { + return map[string]uint64{"error": 0, "debug": 0, "info": 0, "warn": 0, "meta": 0, "other": 0} +} + +func countLogStatus(location string, statusKey string) { + vars.Mutex.Lock() + defer vars.Mutex.Unlock() + + if vars.Container_Stat_Counter[location] == nil { + vars.Container_Stat_Counter[location] = newStatCounter() + } + vars.Container_Stat_Counter[location][statusKey]++ +} + func PutLogMessage(db *leveldb.DB, host string, container string, message_item []string) error { if db == nil { return fmt.Errorf("no database for %s/%s", host, container) @@ -276,24 +290,24 @@ func PutLogMessage(db *leveldb.DB, host string, container string, message_item [ location := host + "/" + container status_key := GetLogStatusKey(message_item[1]) logKey := buildLogKey(message_item[0]) - vars.Mutex.Lock() - if vars.Statuses_DBs[location] == nil { - vars.Statuses_DBs[location] = util.GetDB(host, container, "statuses") + + // Resolved before taking vars.Mutex: GetDB takes DBMutex and can panic, and + // it already caches the handle under that lock. + statusesDB := util.GetDB(host, container, "statuses") + + countLogStatus(location, status_key) + + if statusesDB != nil { + statusesDB.Put([]byte(logKey), []byte(status_key), nil) } - vars.Container_Stat_Counter[location][status_key]++ - vars.Statuses_DBs[location].Put([]byte(logKey), []byte(status_key), nil) - vars.Mutex.Unlock() err := db.Put([]byte(logKey), []byte(message_item[1]), nil) - tries := 0 - for err != nil && tries < 10 { - db = util.GetDB(host, container, "logs") - err = db.Put([]byte(logKey), []byte(message_item[1]), nil) + for tries := 0; err != nil && tries < 10; tries++ { time.Sleep(10 * time.Millisecond) - tries++ - } - if err != nil { - panic(err) + if reopened := util.GetDB(host, container, "logs"); reopened != nil { + db = reopened + err = db.Put([]byte(logKey), []byte(message_item[1]), nil) + } } return err } @@ -452,6 +466,10 @@ func DeleteContainer(host string, container string, fullDelete bool) { return } + for _, dbType := range []string{"logs", "statuses", "statistics", "streamstate"} { + util.ResetDB(host, container, dbType) + } + path := "leveldb/hosts/" + host + "/containers/" + container if fullDelete { os.RemoveAll(path) @@ -462,20 +480,11 @@ func DeleteContainer(host string, container string, fullDelete bool) { } } - if vars.ActiveDBs[container] != nil { - vars.ActiveDBs[container].Close() - vars.ActiveDBs[container] = util.GetDB(host, container, "active") - } - if vars.Statuses_DBs[host+"/"+container] != nil { - vars.Statuses_DBs[host+"/"+container].Close() - vars.Statuses_DBs[host+"/"+container] = util.GetDB(host, container, "statuses") - } - if vars.Stat_Containers_DBs[host+"/"+container] != nil { - vars.Stat_Containers_DBs[host+"/"+container].Close() - vars.Statuses_DBs[host+"/"+container] = util.GetDB(host, container, "statistics") + for _, dbType := range []string{"logs", "statuses", "statistics", "streamstate"} { + util.ResetDB(host, container, dbType) } vars.Mutex.Lock() - vars.Container_Stat_Counter[host+"/"+container] = map[string]uint64{"error": 0, "debug": 0, "info": 0, "warn": 0, "meta": 0, "other": 0} + vars.Container_Stat_Counter[host+"/"+container] = newStatCounter() vars.Mutex.Unlock() } diff --git a/application/backend/app/containerdb/mutex_test.go b/application/backend/app/containerdb/mutex_test.go new file mode 100644 index 0000000..11483b9 --- /dev/null +++ b/application/backend/app/containerdb/mutex_test.go @@ -0,0 +1,126 @@ +package containerdb + +import ( + "os" + "testing" + "time" + + "github.com/devforth/OnLogs/app/vars" + "github.com/syndtr/goleveldb/leveldb" +) + +func mutexIsFree(d time.Duration) bool { + acquired := make(chan struct{}) + go func() { + vars.Mutex.Lock() + vars.Mutex.Unlock() + close(acquired) + }() + select { + case <-acquired: + return true + case <-time.After(d): + return false + } +} + +func TestPutLogMessageHandlesAContainerWithNoStatCounterYet(t *testing.T) { + host := "FirstLineHost" + container := "FirstLineContainer" + location := host + "/" + container + _ = os.RemoveAll("leveldb/hosts/" + host + "/containers/" + container) + + // The first log line from a host/container the process has not seen: no + // counter has been created for it yet. + vars.Mutex.Lock() + delete(vars.Container_Stat_Counter, location) + vars.Mutex.Unlock() + + db, err := leveldb.OpenFile("leveldb/hosts/"+host+"/containers/"+container+"/logs", nil) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + panicked := make(chan interface{}, 1) + go func() { + defer func() { panicked <- recover() }() + PutLogMessage(db, host, container, []string{vars.Year + "-02-10T12:56:09.230421754Z", "first line"}) + }() + + if r := <-panicked; r != nil { + t.Errorf("the first log line for a new container panicked: %v", r) + } + + if !mutexIsFree(3 * time.Second) { + t.Fatal("vars.Mutex was left locked, which freezes ingestion from every source host-wide") + } + + vars.Mutex.Lock() + counter := vars.Container_Stat_Counter[location] + vars.Mutex.Unlock() + if counter == nil { + t.Fatal("no stat counter was created for the container") + } + if counter["other"] != 1 { + t.Errorf("expected the line to be counted once, got %v", counter) + } +} + +func TestPutLogMessageCountsConcurrentlyWithoutRacing(t *testing.T) { + host := "ConcurrentHost" + container := "ConcurrentContainer" + _ = os.RemoveAll("leveldb/hosts/" + host + "/containers/" + container) + + vars.Mutex.Lock() + delete(vars.Container_Stat_Counter, host+"/"+container) + vars.Mutex.Unlock() + + db, err := leveldb.OpenFile("leveldb/hosts/"+host+"/containers/"+container+"/logs", nil) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + const writers = 8 + done := make(chan error, writers) + for i := 0; i < writers; i++ { + go func() { + var failure error + defer func() { + if r := recover(); r != nil { + failure = errFromPanic(r) + } + done <- failure + }() + for n := 0; n < 25; n++ { + PutLogMessage(db, host, container, []string{vars.Year + "-02-10T12:56:09.230421754Z", "concurrent line"}) + } + }() + } + for i := 0; i < writers; i++ { + if err := <-done; err != nil { + t.Fatalf("concurrent ingestion panicked: %v", err) + } + } + + if !mutexIsFree(3 * time.Second) { + t.Fatal("vars.Mutex was left locked after concurrent ingestion") + } +} + +type panicError struct{ value interface{} } + +func (e panicError) Error() string { return "panic: " + toString(e.value) } + +func errFromPanic(r interface{}) error { return panicError{r} } + +func toString(v interface{}) string { + if s, ok := v.(string); ok { + return s + } + if e, ok := v.(error); ok { + return e.Error() + } + return "unknown" +} diff --git a/application/backend/app/containerdb/reset_test.go b/application/backend/app/containerdb/reset_test.go new file mode 100644 index 0000000..36f87da --- /dev/null +++ b/application/backend/app/containerdb/reset_test.go @@ -0,0 +1,91 @@ +package containerdb + +import ( + "os" + "testing" + + "github.com/devforth/OnLogs/app/util" + "github.com/devforth/OnLogs/app/vars" +) + +func TestDeleteContainerLeavesUsableHandlesBehind(t *testing.T) { + host, container := "ResetHost", "ResetContainer" + location := host + "/" + container + base := "leveldb/hosts/" + host + "/containers/" + container + _ = os.RemoveAll("leveldb/hosts/" + host) + t.Cleanup(func() { _ = os.RemoveAll("leveldb/hosts/" + host) }) + + // Warm the caches the way ingestion does. + logsDB := util.GetDB(host, container, "logs") + if logsDB == nil { + t.Fatal("could not open the logs database") + } + util.GetDB(host, container, "statuses") + util.GetDB(host, container, "statistics") + + if err := PutLogMessage(logsDB, host, container, []string{vars.Year + "-02-10T12:56:09.230421754Z", "before delete"}); err != nil { + t.Fatal(err) + } + + DeleteContainer(host, container, false) + + // A stray "active" directory means writes went somewhere nothing reads. + if _, err := os.Stat(base + "/active"); err == nil { + t.Error("DeleteContainer created a stray active/ directory; later log writes go there and are never read back") + } + + // The statuses slot must not be holding the statistics database. + vars.DBMutex.RLock() + statuses := vars.Statuses_DBs[location] + stats := vars.Stat_Containers_DBs[location] + vars.DBMutex.RUnlock() + if statuses != nil && statuses == stats { + t.Error("the statistics database was stored in the statuses slot") + } + + // Ingestion must work again, and land in logs/. + reopened := util.GetDB(host, container, "logs") + if reopened == nil { + t.Fatal("the logs database could not be reopened after a delete") + } + if err := PutLogMessage(reopened, host, container, []string{vars.Year + "-02-10T12:57:09.230421754Z", "after delete"}); err != nil { + t.Fatalf("ingestion failed after a delete: %v", err) + } + + logs := GetLogs(false, false, host, container, "", 30, "", false, nil)["logs"].([][]string) + if len(logs) != 1 || logs[0][1] != "after delete" { + t.Fatalf("expected the post-delete line to be readable back, got %v", logs) + } +} + +func TestLogsOfSameNamedContainersOnDifferentHostsStaySeparate(t *testing.T) { + container := "sharedname" + for _, host := range []string{"HostAlpha", "HostBeta"} { + _ = os.RemoveAll("leveldb/hosts/" + host) + } + t.Cleanup(func() { + for _, host := range []string{"HostAlpha", "HostBeta"} { + _ = os.RemoveAll("leveldb/hosts/" + host) + } + }) + + for _, host := range []string{"HostAlpha", "HostBeta"} { + db := util.GetDB(host, container, "logs") + if db == nil { + t.Fatalf("could not open the logs database for %s", host) + } + if err := PutLogMessage(db, host, container, []string{vars.Year + "-02-10T12:56:09.230421754Z", "line from " + host}); err != nil { + t.Fatal(err) + } + } + + for _, host := range []string{"HostAlpha", "HostBeta"} { + logs := GetLogs(false, false, host, container, "", 30, "", false, nil)["logs"].([][]string) + if len(logs) != 1 { + t.Fatalf("%s: expected exactly its own line, got %d rows: %v", host, len(logs), logs) + } + if logs[0][1] != "line from "+host { + t.Fatalf("%s: read back another host's line: %q", host, logs[0][1]) + } + } +} diff --git a/application/backend/app/daemon/daemon.go b/application/backend/app/daemon/daemon.go index 0193205..8cc47cc 100644 --- a/application/backend/app/daemon/daemon.go +++ b/application/backend/app/daemon/daemon.go @@ -20,6 +20,7 @@ import ( "github.com/devforth/OnLogs/app/vars" "github.com/docker/docker/api/types/container" "github.com/docker/docker/pkg/stdcopy" + "github.com/gorilla/websocket" "github.com/syndtr/goleveldb/leveldb" ) @@ -80,13 +81,7 @@ func validateMessage(message string) (string, bool) { } func closeActiveStream(containerName string) { - newDaemonStreams := make([]string, 0, len(vars.Active_Daemon_Streams)) - for _, stream := range vars.Active_Daemon_Streams { - if stream != containerName { - newDaemonStreams = append(newDaemonStreams, stream) - } - } - vars.Active_Daemon_Streams = newDaemonStreams + vars.RemoveActiveStream(containerName) } func normalizeTimestamp(raw string) (string, time.Time, error) { @@ -294,9 +289,7 @@ func (h *DaemonService) runContainerStream(ctx context.Context, containerName st h.saveCursor(host, containerName, cursorTS) toSend, _ := json.Marshal(logItem) - for _, c := range vars.Connections[containerName] { - c.WriteMessage(1, toSend) - } + vars.Broadcast(containerName, websocket.TextMessage, toSend) }, !h.isContainerTTY(ctx, containerName)) if streamErr != nil && ctx.Err() == nil { @@ -333,11 +326,9 @@ func (h *DaemonService) EnsureStream(ctx context.Context, containerName string) h.streamIDs[containerName] = streamID h.streamsMu.Unlock() - if !util.Contains(containerName, vars.Active_Daemon_Streams) { - vars.Active_Daemon_Streams = append(vars.Active_Daemon_Streams, containerName) - } + vars.AddActiveStream(containerName) - if os.Getenv("AGENT") != "" { + if util.IsAgentMode() { go h.runContainerStream(streamCtx, containerName, true, streamID) return } @@ -374,7 +365,7 @@ func (h *DaemonService) GetContainersList(ctx context.Context) []string { result, err := h.DockerClient.GetContainerNames(ctx) if err != nil { fmt.Println("ERROR: failed to get containers list from docker daemon:", err) - return vars.DockerContainers + return vars.DockerContainerList() } var names []string diff --git a/application/backend/app/routes/routes.go b/application/backend/app/routes/routes.go index c9a617f..5d2b0fc 100644 --- a/application/backend/app/routes/routes.go +++ b/application/backend/app/routes/routes.go @@ -2,6 +2,7 @@ package routes import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -26,8 +27,8 @@ import ( ) type RouteController struct { - DockerService *docker.DockerService - DaemonService *daemon.DaemonService + DockerService *docker.DockerService + DaemonService *daemon.DaemonService } func enableCors(w *http.ResponseWriter) { @@ -123,7 +124,7 @@ func verifyRequest(w *http.ResponseWriter, req *http.Request) bool { return false } -func (h *RouteController)Frontend(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) Frontend(w http.ResponseWriter, req *http.Request) { // http.Dir sanitises the name it is given but never its own root. dir := http.Dir("dist") @@ -161,7 +162,7 @@ func (h *RouteController)Frontend(w http.ResponseWriter, req *http.Request) { http.ServeContent(w, req, fileName, stat.ModTime(), bytes.NewReader(content)) } -func (h *RouteController)CheckCookie(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) CheckCookie(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyUser(&w, req) { return } @@ -169,7 +170,7 @@ func (h *RouteController)CheckCookie(w http.ResponseWriter, req *http.Request) { json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) } -func (h *RouteController)AddLogLine(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) AddLogLine(w http.ResponseWriter, req *http.Request) { var logItem struct { Token string Host string @@ -190,9 +191,7 @@ func (h *RouteController)AddLogLine(w http.ResponseWriter, req *http.Request) { return } - if vars.Counters_For_Hosts_Last_30_Min[logItem.Host] == nil { - go statistics.RunStatisticForContainer(logItem.Host, logItem.Container) - } + statistics.EnsureWorker(context.Background(), logItem.Host, logItem.Container) err := containerdb.PutLogMessage(util.GetDB(logItem.Host, logItem.Container, "logs"), logItem.Host, logItem.Container, logItem.LogLine) if err != nil { defer w.WriteHeader(http.StatusInternalServerError) @@ -200,14 +199,10 @@ func (h *RouteController)AddLogLine(w http.ResponseWriter, req *http.Request) { } to_send, _ := json.Marshal([]string{logItem.LogLine[0], logItem.LogLine[1]}) - conns := vars.Connections[logItem.Host+"/"+logItem.Container] - for i := range conns { - c := conns[i] - c.WriteMessage(websocket.TextMessage, to_send) - } + vars.Broadcast(logItem.Host+"/"+logItem.Container, websocket.TextMessage, to_send) } -func (h *RouteController)AddHost(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) AddHost(w http.ResponseWriter, req *http.Request) { if req.Method != "POST" { w.WriteHeader(http.StatusNotFound) return @@ -238,14 +233,14 @@ func (h *RouteController)AddHost(w http.ResponseWriter, req *http.Request) { } } - vars.AgentsActiveContainers[addReq.Hostname] = addReq.Services + vars.SetAgentContainers(addReq.Hostname, addReq.Services) // fmt.Println("New host added: " + addReq.Hostname) need to create separate route for SendUpdate func for _, container := range addReq.Services { os.MkdirAll("leveldb/hosts/"+addReq.Hostname+"/containers/"+container, 0700) } } -func (h *RouteController)ChangeFavourite(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) ChangeFavourite(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyUser(&w, req) { return } @@ -275,7 +270,7 @@ func (h *RouteController)ChangeFavourite(w http.ResponseWriter, req *http.Reques json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) } -func (h *RouteController)GetSecret(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) GetSecret(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyAdminUser(&w, req) { return } @@ -284,7 +279,7 @@ func (h *RouteController)GetSecret(w http.ResponseWriter, req *http.Request) { json.NewEncoder(w).Encode(map[string]string{"token": db.CreateOnLogsToken()}) } -func (h *RouteController)GetChartData(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) GetChartData(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyUser(&w, req) { return } @@ -337,7 +332,7 @@ func (h *RouteController) GetHosts(w http.ResponseWriter, req *http.Request) { allContainers := []map[string]interface{}{} for _, container := range containers { isFavorite, _ := vars.FavsDB.Has([]byte(util.GetHost()+"/"+container.Name()), nil) - if util.Contains(container.Name(), activeContainers) || util.Contains(container.Name(), vars.AgentsActiveContainers[host.Name()]) { + if util.Contains(container.Name(), activeContainers) || util.Contains(container.Name(), vars.AgentContainers(host.Name())) { allContainers = append(allContainers, map[string]interface{}{"serviceName": container.Name(), "isDisabled": false, "isFavorite": isFavorite}) } else { allContainers = append(allContainers, map[string]interface{}{"serviceName": container.Name(), "isDisabled": true, "isFavorite": isFavorite}) @@ -351,7 +346,7 @@ func (h *RouteController) GetHosts(w http.ResponseWriter, req *http.Request) { w.Write(e) } -func (h *RouteController)GetSizeByAll(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) GetSizeByAll(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyUser(&w, req) { return } @@ -373,7 +368,7 @@ func (h *RouteController)GetSizeByAll(w http.ResponseWriter, req *http.Request) } // TODO need to return 0.0 when there is no logs for container in db -func (h *RouteController)GetSizeByService(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) GetSizeByService(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyUser(&w, req) { return } @@ -396,7 +391,7 @@ func (h *RouteController)GetSizeByService(w http.ResponseWriter, req *http.Reque json.NewEncoder(w).Encode(map[string]interface{}{"sizeMiB": fmt.Sprintf("%.1f", size)}) // MiB } -func (h *RouteController)GetDockerSize(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) GetDockerSize(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyUser(&w, req) { return } @@ -422,7 +417,7 @@ func (h *RouteController)GetDockerSize(w http.ResponseWriter, req *http.Request) json.NewEncoder(w).Encode(map[string]interface{}{"sizeMiB": fmt.Sprintf("%.1f", size)}) // MiB } -func (h *RouteController)GetStats(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) GetStats(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyUser(&w, req) { return } @@ -440,7 +435,7 @@ func (h *RouteController)GetStats(w http.ResponseWriter, req *http.Request) { json.NewEncoder(w).Encode(statistics.GetStatisticsByService(data.Host, data.Service, data.Value)) } -func (h *RouteController)GetStorageData(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) GetStorageData(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyUser(&w, req) { return } @@ -462,7 +457,7 @@ func (h *RouteController)GetStorageData(w http.ResponseWriter, req *http.Request json.NewEncoder(w).Encode(util.GetStorageData()) } -func (h *RouteController)GetPrevLogs(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) GetPrevLogs(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyUser(&w, req) { return } @@ -486,7 +481,7 @@ func (h *RouteController)GetPrevLogs(w http.ResponseWriter, req *http.Request) { json.NewEncoder(w).Encode(containerdb.GetLogs(true, false, params.Get("host"), params.Get("id"), params.Get("search"), limit, params.Get("startWith"), caseSensetive, nil)) } -func (h *RouteController)GetLogs(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) GetLogs(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyUser(&w, req) { return } @@ -514,7 +509,7 @@ func (h *RouteController)GetLogs(w http.ResponseWriter, req *http.Request) { )) } -func (h *RouteController)GetLogWithPrev(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) GetLogWithPrev(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyUser(&w, req) { return } @@ -529,7 +524,7 @@ func (h *RouteController)GetLogWithPrev(w http.ResponseWriter, req *http.Request } // TODO return {"error": "Invalid host!"} when host is not exists -func (h *RouteController)GetLogsStream(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) GetLogsStream(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyUser(&w, req) { return } @@ -561,10 +556,10 @@ func (h *RouteController)GetLogsStream(w http.ResponseWriter, req *http.Request) fmt.Println(err) return } - vars.Connections[container] = append(vars.Connections[container], ws) + vars.AddConnection(container, ws) } -func (h *RouteController)Login(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) Login(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) { json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) return @@ -611,7 +606,7 @@ func (h *RouteController)Login(w http.ResponseWriter, req *http.Request) { json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) } -func (h *RouteController)Logout(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) Logout(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyUser(&w, req) { return } @@ -629,7 +624,7 @@ func (h *RouteController)Logout(w http.ResponseWriter, req *http.Request) { json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) } -func (h *RouteController)CreateUser(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) CreateUser(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyAdminUser(&w, req) { return } @@ -653,7 +648,7 @@ func (h *RouteController)CreateUser(w http.ResponseWriter, req *http.Request) { json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) } -func (h *RouteController)GetUsers(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) GetUsers(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyAdminUser(&w, req) { return } @@ -663,7 +658,7 @@ func (h *RouteController)GetUsers(w http.ResponseWriter, req *http.Request) { json.NewEncoder(w).Encode(map[string]interface{}{"users": users, "error": nil}) } -func (h *RouteController)UpdateUserSettings(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) UpdateUserSettings(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyUser(&w, req) { return } @@ -677,7 +672,7 @@ func (h *RouteController)UpdateUserSettings(w http.ResponseWriter, req *http.Req json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) } -func (h *RouteController)GetUserSettings(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) GetUserSettings(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyUser(&w, req) { return } @@ -687,7 +682,7 @@ func (h *RouteController)GetUserSettings(w http.ResponseWriter, req *http.Reques json.NewEncoder(w).Encode(userdb.GetUserSettings(username)) } -func (h *RouteController)EditHostname(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) EditHostname(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyAdminUser(&w, req) { return } @@ -712,7 +707,7 @@ func (h *RouteController)EditHostname(w http.ResponseWriter, req *http.Request) json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) } -func (h *RouteController)EditUser(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) EditUser(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyAdminUser(&w, req) { return } @@ -737,7 +732,7 @@ func (h *RouteController)EditUser(w http.ResponseWriter, req *http.Request) { json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) } -func (h *RouteController)DeleteContainerLogs(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) DeleteContainerLogs(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyAdminUser(&w, req) { return } @@ -755,7 +750,7 @@ func (h *RouteController)DeleteContainerLogs(w http.ResponseWriter, req *http.Re json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) } -func (h *RouteController)DeleteDockerLogs(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) DeleteDockerLogs(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyAdminUser(&w, req) { return } @@ -772,7 +767,7 @@ func (h *RouteController)DeleteDockerLogs(w http.ResponseWriter, req *http.Reque json.NewEncoder(w).Encode(map[string]interface{}{"error": util.DeleteDockerLogs(logItem.Host, logItem.Service)}) } -func (h *RouteController)AskForDelete(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) AskForDelete(w http.ResponseWriter, req *http.Request) { var logItem struct { Hostname string Token string @@ -786,17 +781,13 @@ func (h *RouteController)AskForDelete(w http.ResponseWriter, req *http.Request) return } - to_delete := []string{} - if len(vars.ToDelete[logItem.Hostname]) != 0 { - to_delete = vars.ToDelete[logItem.Hostname] - vars.ToDelete[logItem.Hostname] = []string{} - } + to_delete := vars.TakeQueuedDeletes(logItem.Hostname) w.Header().Add("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"Services": to_delete}) } -func (h *RouteController)DeleteContainer(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) DeleteContainer(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyAdminUser(&w, req) { return } @@ -824,7 +815,7 @@ func (h *RouteController)DeleteContainer(w http.ResponseWriter, req *http.Reques json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) } -func (h *RouteController)DeleteUser(w http.ResponseWriter, req *http.Request) { +func (h *RouteController) DeleteUser(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyAdminUser(&w, req) { return } diff --git a/application/backend/app/routes/security_test.go b/application/backend/app/routes/security_test.go index e40c017..0c09c6d 100644 --- a/application/backend/app/routes/security_test.go +++ b/application/backend/app/routes/security_test.go @@ -1,23 +1,24 @@ package routes import ( - "crypto/tls" - "strconv" - "bytes" + "crypto/tls" "encoding/json" - "github.com/devforth/OnLogs/app/userdb" - "github.com/devforth/OnLogs/app/vars" - - "github.com/devforth/OnLogs/app/db" "io" "net/http" "net/http/httptest" "os" + "runtime" + "strconv" "strings" "testing" + "time" + "github.com/devforth/OnLogs/app/db" + "github.com/devforth/OnLogs/app/statistics" + "github.com/devforth/OnLogs/app/userdb" "github.com/devforth/OnLogs/app/util" + "github.com/devforth/OnLogs/app/vars" ) func TestFrontendDoesNotServeFilesOutsideDist(t *testing.T) { @@ -262,3 +263,44 @@ func TestLoginRateLimitsRepeatedFailures(t *testing.T) { t.Fatalf("20 failed logins from one address did not trigger a backoff: status %d", code) } } + +func TestAddLogLineDoesNotSpawnAWorkerPerLogLine(t *testing.T) { + ctrl := initTestConfig() + token := db.CreateOnLogsToken() + before := statistics.WorkerCount() + t.Cleanup(func() { statistics.StopWorker("statsprobehost", "statsprobecontainer") }) + + ingest := func(count int) { + for i := 0; i < count; i++ { + body, _ := json.Marshal(map[string]interface{}{ + "Token": token, + "Host": "statsprobehost", + "Container": "statsprobecontainer", + "LogLine": []string{"2026-02-10T12:56:09.230421754Z", "line " + strconv.Itoa(i)}, + }) + req, _ := http.NewRequest("POST", "/api/v1/addLogLine", bytes.NewBuffer(body)) + rr := httptest.NewRecorder() + http.HandlerFunc(ctrl.AddLogLine).ServeHTTP(rr, req) + if code := rr.Result().StatusCode; code != http.StatusOK { + t.Fatalf("ingestion failed: status %d", code) + } + } + time.Sleep(250 * time.Millisecond) + } + + // Warm up: the first line legitimately starts one worker, which opens + // databases and so brings its own goroutines with it. + ingest(25) + settled := runtime.NumGoroutine() + + // A second, identical batch must cost nothing: the worker already exists. + ingest(25) + growth := runtime.NumGoroutine() - settled + + if got := statistics.WorkerCount() - before; got != 1 { + t.Errorf("expected exactly one registered statistics worker, got %d", got) + } + if growth > 5 { + t.Fatalf("a further 25 log lines started %d more goroutines; the leak scales with ingestion and each worker zeroes the live counter", growth) + } +} diff --git a/application/backend/app/statistics/race_test.go b/application/backend/app/statistics/race_test.go new file mode 100644 index 0000000..4abee7e --- /dev/null +++ b/application/backend/app/statistics/race_test.go @@ -0,0 +1,78 @@ +package statistics + +import ( + "os" + "testing" + + "github.com/devforth/OnLogs/app/vars" +) + +// Mirrors containerdb.countLogStatus: the writer that runs on every log line. +func countConcurrently(location string, stop <-chan struct{}, done chan<- struct{}) { + for { + select { + case <-stop: + close(done) + return + default: + } + vars.Mutex.Lock() + if vars.Container_Stat_Counter[location] == nil { + vars.Container_Stat_Counter[location] = map[string]uint64{} + } + vars.Container_Stat_Counter[location]["error"]++ + vars.Mutex.Unlock() + } +} + +func TestGetStatisticsByServiceDoesNotHandOutTheLiveCounter(t *testing.T) { + host, service := "StatRaceHost", "StatRaceCont" + location := host + "/" + service + _ = os.RemoveAll("leveldb/hosts/" + host) + + vars.Mutex.Lock() + vars.Container_Stat_Counter[location] = map[string]uint64{"error": 0, "debug": 0, "info": 0, "warn": 0, "meta": 0, "other": 0} + vars.Mutex.Unlock() + + stop := make(chan struct{}) + done := make(chan struct{}) + go countConcurrently(location, stop, done) + + // value < 1 is the path /getStats takes for the live counters, and the + // returned map is then marshalled by the handler with no lock held. + for i := 0; i < 300; i++ { + result := GetStatisticsByService(host, service, 0) + for _, key := range []string{"error", "debug", "info", "warn", "meta", "other"} { + _ = result[key] + } + } + + close(stop) + <-done +} + +func TestGetChartDataDoesNotHandOutTheLiveCounter(t *testing.T) { + host, service := "ChartRaceHost", "ChartRaceCont" + location := host + "/" + service + _ = os.RemoveAll("leveldb/hosts/" + host) + + vars.Mutex.Lock() + vars.Container_Stat_Counter[location] = map[string]uint64{"error": 0, "debug": 0, "info": 0, "warn": 0, "meta": 0, "other": 0} + vars.Mutex.Unlock() + + stop := make(chan struct{}) + done := make(chan struct{}) + go countConcurrently(location, stop, done) + + for i := 0; i < 200; i++ { + result := GetChartData(host, service, "hour", 2) + if now := result["now"]; now != nil { + for _, key := range []string{"error", "debug", "info", "warn", "meta", "other"} { + _ = now[key] + } + } + } + + close(stop) + <-done +} diff --git a/application/backend/app/statistics/registry.go b/application/backend/app/statistics/registry.go new file mode 100644 index 0000000..ebdf375 --- /dev/null +++ b/application/backend/app/statistics/registry.go @@ -0,0 +1,67 @@ +package statistics + +import ( + "context" + "sync" +) + +// One statistics worker per host/container, shared by the docker streamer and +// the agent ingestion route. Without a single registry each ingested log line +// spawned another immortal worker, and every new worker zeroes the live counter. +var ( + workersMutex sync.Mutex + workers = map[string]context.CancelFunc{} +) + +func WorkerKey(host string, container string) string { + return host + "/" + container +} + +func registerWorker(location string, cancel context.CancelFunc) bool { + workersMutex.Lock() + defer workersMutex.Unlock() + + if _, exists := workers[location]; exists { + return false + } + workers[location] = cancel + return true +} + +// EnsureWorker starts a worker for host/container unless one already runs. +func EnsureWorker(ctx context.Context, host string, container string) bool { + location := WorkerKey(host, container) + workerCtx, cancel := context.WithCancel(ctx) + + if !registerWorker(location, cancel) { + cancel() + return false + } + + go func() { + defer StopWorker(host, container) + RunStatisticForContainerWithContext(workerCtx, host, container) + }() + return true +} + +func StopWorker(host string, container string) { + location := WorkerKey(host, container) + + workersMutex.Lock() + cancel, exists := workers[location] + if exists { + delete(workers, location) + } + workersMutex.Unlock() + + if exists { + cancel() + } +} + +func WorkerCount() int { + workersMutex.Lock() + defer workersMutex.Unlock() + return len(workers) +} diff --git a/application/backend/app/statistics/statistics.go b/application/backend/app/statistics/statistics.go index 93e57fa..e5d3228 100644 --- a/application/backend/app/statistics/statistics.go +++ b/application/backend/app/statistics/statistics.go @@ -12,6 +12,22 @@ import ( "github.com/syndtr/goleveldb/leveldb" ) +func emptyStats() map[string]uint64 { + return map[string]uint64{"error": 0, "debug": 0, "info": 0, "warn": 0, "meta": 0, "other": 0} +} + +// The caller must never receive the live map: it is written on every log line. +func snapshotCounter(location string) map[string]uint64 { + vars.Mutex.Lock() + defer vars.Mutex.Unlock() + + snapshot := emptyStats() + for key, value := range vars.Container_Stat_Counter[location] { + snapshot[key] = value + } + return snapshot +} + func restartStats(host string, container string) { current_db := util.GetDB(host, container, "statistics") if current_db == nil { @@ -106,14 +122,14 @@ func saveStats(db *leveldb.DB, stats map[string]uint64, timestamp string) { func resetInMemoryStats(location string) { vars.Mutex.Lock() - vars.Container_Stat_Counter[location] = map[string]uint64{"error": 0, "debug": 0, "info": 0, "warn": 0, "meta": 0, "other": 0} + vars.Container_Stat_Counter[location] = emptyStats() vars.Mutex.Unlock() } func RunStatisticForContainerWithContext(ctx context.Context, host string, container string) { location := host + "/" + container vars.Mutex.Lock() - vars.Container_Stat_Counter[location] = map[string]uint64{"error": 0, "debug": 0, "info": 0, "warn": 0, "meta": 0, "other": 0} + vars.Container_Stat_Counter[location] = emptyStats() vars.Mutex.Unlock() defer restartStats(host, container) for { @@ -137,14 +153,7 @@ func RunStatisticForContainer(host string, container string) { func GetStatisticsByService(host string, service string, value int) map[string]uint64 { location := host + "/" + service - - vars.Mutex.Lock() - to_return := vars.Container_Stat_Counter[location] - vars.Mutex.Unlock() - - if to_return == nil { - to_return = map[string]uint64{"error": 0, "debug": 0, "info": 0, "warn": 0, "meta": 0, "other": 0} - } + to_return := snapshotCounter(location) if value < 1 { return to_return @@ -232,9 +241,7 @@ func GetChartData(host string, service string, unit string, uAmount int) map[str hasPrev = iter.Prev() } - vars.Mutex.Lock() - to_return["now"] = vars.Container_Stat_Counter[location] - vars.Mutex.Unlock() + to_return["now"] = snapshotCounter(location) return to_return } diff --git a/application/backend/app/statistics/statistics_test.go b/application/backend/app/statistics/statistics_test.go index 8ac05db..eea8288 100644 --- a/application/backend/app/statistics/statistics_test.go +++ b/application/backend/app/statistics/statistics_test.go @@ -11,13 +11,23 @@ import ( "github.com/syndtr/goleveldb/leveldb" ) +// Reads the shared maps under the same locks the production writers hold; this +// test read them bare, which is why the suite could not run under -race. func TestRunStatisticForContainer(t *testing.T) { go RunStatisticForContainer("Test", "TestContainer") time.Sleep(1 * time.Second) - if vars.Container_Stat_Counter["Test/TestContainer"] == nil { + + vars.Mutex.Lock() + counter := vars.Container_Stat_Counter["Test/TestContainer"] + vars.Mutex.Unlock() + if counter == nil { t.Error("No counter variable for container was created!") } - if vars.Stat_Containers_DBs["Test/TestContainer"] == nil { + + vars.DBMutex.RLock() + statsDB := vars.Stat_Containers_DBs["Test/TestContainer"] + vars.DBMutex.RUnlock() + if statsDB == nil { t.Error("DB for stats wasn't created!") } } diff --git a/application/backend/app/streamer/streamer.go b/application/backend/app/streamer/streamer.go index bb4a451..e7a898a 100644 --- a/application/backend/app/streamer/streamer.go +++ b/application/backend/app/streamer/streamer.go @@ -3,10 +3,8 @@ package streamer import ( "context" "fmt" - "os" "strconv" "strings" - "sync" "time" "github.com/devforth/OnLogs/app/agent" @@ -19,59 +17,22 @@ import ( type StreamController struct { DaemonService *daemon.DaemonService - - statsMu sync.Mutex - statsCancels map[string]context.CancelFunc } func getStatsWorkerKey(host, container string) string { - return host + "/" + container -} - -func (ctrl *StreamController) registerStatisticsWorker(location string, cancel context.CancelFunc) bool { - ctrl.statsMu.Lock() - defer ctrl.statsMu.Unlock() - if ctrl.statsCancels == nil { - ctrl.statsCancels = map[string]context.CancelFunc{} - } - if _, exists := ctrl.statsCancels[location]; exists { - return false - } - ctrl.statsCancels[location] = cancel - return true -} - -func (ctrl *StreamController) unregisterStatisticsWorker(location string) (context.CancelFunc, bool) { - ctrl.statsMu.Lock() - defer ctrl.statsMu.Unlock() - cancel, exists := ctrl.statsCancels[location] - if exists { - delete(ctrl.statsCancels, location) - } - return cancel, exists + return statistics.WorkerKey(host, container) } func (ctrl *StreamController) ensureStatisticsWorker(ctx context.Context, host, container string) { - location := getStatsWorkerKey(host, container) - workerCtx, cancel := context.WithCancel(ctx) - if !ctrl.registerStatisticsWorker(location, cancel) { - cancel() - return - } - go statistics.RunStatisticForContainerWithContext(workerCtx, host, container) + statistics.EnsureWorker(ctx, host, container) } func (ctrl *StreamController) stopStatisticsWorker(host, container string) { - cancel, exists := ctrl.unregisterStatisticsWorker(getStatsWorkerKey(host, container)) - if exists { - cancel() - } + statistics.StopWorker(host, container) } func (ctrl *StreamController) statisticsWorkersCount() int { - ctrl.statsMu.Lock() - defer ctrl.statsMu.Unlock() - return len(ctrl.statsCancels) + return statistics.WorkerCount() } func (ctrl *StreamController) ensureStreams(ctx context.Context, containers []string) { @@ -83,14 +44,15 @@ func (ctrl *StreamController) ensureStreams(ctx context.Context, containers []st } func (ctrl *StreamController) reconcileStreams(ctx context.Context) { + containers := vars.DockerContainerList() current := map[string]struct{}{} - for _, container := range vars.DockerContainers { + for _, container := range containers { current[container] = struct{}{} } - ctrl.ensureStreams(ctx, vars.DockerContainers) + ctrl.ensureStreams(ctx, containers) - for _, active := range append([]string{}, vars.Active_Daemon_Streams...) { + for _, active := range vars.ActiveStreams() { if _, exists := current[active]; !exists { ctrl.DaemonService.StopStream(active) ctrl.stopStatisticsWorker(util.GetHost(), active) @@ -106,9 +68,7 @@ func (ctrl *StreamController) handleContainerEvent(ctx context.Context, msg even switch msg.Action { case "start", "restart", "unpause": - if !util.Contains(containerName, vars.DockerContainers) { - vars.DockerContainers = append(vars.DockerContainers, containerName) - } + vars.AddDockerContainer(containerName) ctrl.ensureStatisticsWorker(ctx, util.GetHost(), containerName) ctrl.DaemonService.EnsureStream(ctx, containerName) case "die", "stop", "pause": @@ -162,10 +122,10 @@ func (ctrl *StreamController) StreamLogs(ctx context.Context) { return } - vars.DockerContainers = ctrl.DaemonService.GetContainersList(ctx) + vars.SetDockerContainers(ctrl.DaemonService.GetContainersList(ctx)) ctrl.reconcileStreams(ctx) - if os.Getenv("AGENT") != "" { - agent.SendInitRequest(vars.DockerContainers) + if util.IsAgentMode() { + agent.SendInitRequest(vars.DockerContainerList()) } go ctrl.startEventsLoop(ctx) @@ -179,10 +139,10 @@ func (ctrl *StreamController) StreamLogs(ctx context.Context) { return case <-reconcileTicker.C: vars.Year = strconv.Itoa(time.Now().UTC().Year()) - vars.DockerContainers = ctrl.DaemonService.GetContainersList(ctx) + vars.SetDockerContainers(ctrl.DaemonService.GetContainersList(ctx)) ctrl.reconcileStreams(ctx) - if os.Getenv("AGENT") != "" { - agent.SendUpdate(vars.DockerContainers) + if util.IsAgentMode() { + agent.SendUpdate(vars.DockerContainerList()) agent.TryResend() } } diff --git a/application/backend/app/streamer/streamer_test.go b/application/backend/app/streamer/streamer_test.go index 3d83039..cd49b2f 100644 --- a/application/backend/app/streamer/streamer_test.go +++ b/application/backend/app/streamer/streamer_test.go @@ -4,14 +4,22 @@ import ( "context" "fmt" "testing" + + "github.com/devforth/OnLogs/app/statistics" ) +// The registry moved into the statistics package so the docker streamer and the +// agent ingestion route share one worker per host/container. func TestRegisterStatisticsWorkerNoDuplicates(t *testing.T) { ctrl := &StreamController{} - location := getStatsWorkerKey("host", "container") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + baseline := ctrl.statisticsWorkersCount() + t.Cleanup(func() { ctrl.stopStatisticsWorker("host", "container") }) - first := ctrl.registerStatisticsWorker(location, func() {}) - second := ctrl.registerStatisticsWorker(location, func() {}) + first := statistics.EnsureWorker(ctx, "host", "container") + second := statistics.EnsureWorker(ctx, "host", "container") if !first { t.Fatal("first registration must succeed") @@ -19,8 +27,8 @@ func TestRegisterStatisticsWorkerNoDuplicates(t *testing.T) { if second { t.Fatal("duplicate registration must be rejected") } - if ctrl.statisticsWorkersCount() != 1 { - t.Fatalf("expected exactly one worker, got %d", ctrl.statisticsWorkersCount()) + if got := ctrl.statisticsWorkersCount(); got != baseline+1 { + t.Fatalf("expected exactly one new worker, got %d (baseline %d)", got, baseline) } } @@ -29,6 +37,7 @@ func TestStatisticsWorkersLongChurnDoesNotLeak(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + baseline := ctrl.statisticsWorkersCount() host := "churn-host" for i := 0; i < 300; i++ { container := fmt.Sprintf("ephemeral-%d", i) @@ -36,7 +45,7 @@ func TestStatisticsWorkersLongChurnDoesNotLeak(t *testing.T) { ctrl.stopStatisticsWorker(host, container) } - if ctrl.statisticsWorkersCount() != 0 { - t.Fatalf("expected zero workers after churn, got %d", ctrl.statisticsWorkersCount()) + if got := ctrl.statisticsWorkersCount(); got != baseline { + t.Fatalf("expected %d workers after churn, got %d", baseline, got) } } diff --git a/application/backend/app/util/agentmode_test.go b/application/backend/app/util/agentmode_test.go new file mode 100644 index 0000000..21729f0 --- /dev/null +++ b/application/backend/app/util/agentmode_test.go @@ -0,0 +1,46 @@ +package util + +import ( + "os" + "testing" +) + +func TestIsAgentModeTreatsTheValueAsABoolean(t *testing.T) { + cases := map[string]bool{ + "": false, + "false": false, + "FALSE": false, + "0": false, + "no": false, + "nonsense": false, + "true": true, + "TRUE": true, + "1": true, + } + + for value, want := range cases { + os.Setenv("AGENT", value) + if got := IsAgentMode(); got != want { + t.Errorf("AGENT=%q: agent mode is %v, want %v", value, got, want) + } + } + os.Unsetenv("AGENT") +} + +func TestParseHostnameHandlesAnEmptyFile(t *testing.T) { + if host := parseHostname("", nil); host == "" { + t.Error("an empty /etc/hostname produced an empty host, which indexes out of range downstream") + } + if host := parseHostname("\n", nil); host == "" { + t.Error("a newline-only /etc/hostname produced an empty host") + } + if host := parseHostname("myhost\n", nil); host != "myhost" { + t.Errorf("trailing newline not stripped: %q", host) + } + if host := parseHostname("myhost\r\n", nil); host != "myhost" { + t.Errorf("trailing CRLF not stripped: %q", host) + } + if host := parseHostname("myhost", nil); host != "myhost" { + t.Errorf("a clean hostname was altered: %q", host) + } +} diff --git a/application/backend/app/util/util.go b/application/backend/app/util/util.go index ef8528b..c3f0322 100644 --- a/application/backend/app/util/util.go +++ b/application/backend/app/util/util.go @@ -134,7 +134,7 @@ func GetDB(host string, container string, dbType string) *leveldb.DB { switch dbType { case "logs": - vars.ActiveDBs[container] = db + vars.ActiveDBs[host+"/"+container] = db case "statistics": vars.Stat_Containers_DBs[host+"/"+container] = db case "hosts_statistics": @@ -142,7 +142,7 @@ func GetDB(host string, container string, dbType string) *leveldb.DB { case "statuses": vars.Statuses_DBs[host+"/"+container] = db case "brokenlogs": - vars.BrokenLogs_DBs[container] = db + vars.BrokenLogs_DBs[host+"/"+container] = db case "containersmeta": vars.ContainersMeta_DBs[host+"/"+container] = db case "streamstate": @@ -152,10 +152,42 @@ func GetDB(host string, container string, dbType string) *leveldb.DB { return db } +// ResetDB closes a cached handle and drops it, so the next GetDB opens a fresh +// one. Closing without dropping leaves callers holding a closed handle. +func ResetDB(host string, container string, dbType string) { + if !IsSafeName(host) || !IsSafeName(container) || !IsSafeName(dbType) { + return + } + + vars.DBMutex.Lock() + defer vars.DBMutex.Unlock() + + if db := getExistingDB(host, container, dbType); db != nil { + db.Close() + } + + switch dbType { + case "logs": + delete(vars.ActiveDBs, host+"/"+container) + case "statistics": + delete(vars.Stat_Containers_DBs, host+"/"+container) + case "hosts_statistics": + delete(vars.Stat_Hosts_DBs, host) + case "statuses": + delete(vars.Statuses_DBs, host+"/"+container) + case "brokenlogs": + delete(vars.BrokenLogs_DBs, host+"/"+container) + case "containersmeta": + delete(vars.ContainersMeta_DBs, host+"/"+container) + case "streamstate": + delete(vars.StreamState_DBs, host+"/"+container) + } +} + func getExistingDB(host, container, dbType string) *leveldb.DB { switch dbType { case "logs": - return vars.ActiveDBs[container] + return vars.ActiveDBs[host+"/"+container] case "statistics": return vars.Stat_Containers_DBs[host+"/"+container] case "hosts_statistics": @@ -163,7 +195,7 @@ func getExistingDB(host, container, dbType string) *leveldb.DB { case "statuses": return vars.Statuses_DBs[host+"/"+container] case "brokenlogs": - return vars.BrokenLogs_DBs[container] + return vars.BrokenLogs_DBs[host+"/"+container] case "containersmeta": return vars.ContainersMeta_DBs[host+"/"+container] case "streamstate": @@ -172,21 +204,36 @@ func getExistingDB(host, container, dbType string) *leveldb.DB { return nil } -func GetHost() string { - hostname, err := os.ReadFile("/etc/hostname") - var host string - if err != nil { - host, _ = os.Hostname() - } else { - host = string(hostname) +// AGENT is documented as a boolean, so "false" must mean off. Testing it with +// != "" turned the documented default into agent mode. +func IsAgentMode() bool { + enabled, err := strconv.ParseBool(os.Getenv("AGENT")) + return err == nil && enabled +} + +func parseHostname(raw string, readErr error) string { + if readErr != nil { + fallback, _ := os.Hostname() + raw = fallback } - if host[len(host)-1] < 32 || host[len(host)-1] > 126 { - host = host[:len(host)-1] + host := strings.TrimFunc(raw, func(r rune) bool { return r < 32 || r > 126 }) + if host == "" { + if fallback, err := os.Hostname(); err == nil { + host = strings.TrimSpace(fallback) + } + } + if host == "" { + host = "localhost" } return host } +func GetHost() string { + hostname, err := os.ReadFile("/etc/hostname") + return parseHostname(string(hostname), err) +} + func GetDirSize(host string, container string) float64 { if !IsSafeName(host) || !IsSafeName(container) { return 0 @@ -289,7 +336,7 @@ func GetDockerContainerID(host string, container string) string { func DeleteDockerLogs(host string, container string) error { if host != GetHost() { - vars.ToDelete[host] = append(vars.ToDelete[host+"/"+container], container) + vars.QueueDelete(host, container) return nil } diff --git a/application/backend/app/vars/connections.go b/application/backend/app/vars/connections.go new file mode 100644 index 0000000..b6fa276 --- /dev/null +++ b/application/backend/app/vars/connections.go @@ -0,0 +1,61 @@ +package vars + +import ( + "time" + + "github.com/gorilla/websocket" +) + +const wsWriteDeadline = 10 * time.Second + +// AddConnection registers a viewer's websocket for a host/container. +func AddConnection(key string, conn *websocket.Conn) { + if conn == nil { + return + } + connectionsMutex.Lock() + defer connectionsMutex.Unlock() + Connections[key] = append(Connections[key], conn) +} + +func connectionsFor(key string) []*websocket.Conn { + connectionsMutex.RLock() + defer connectionsMutex.RUnlock() + return append([]*websocket.Conn{}, Connections[key]...) +} + +func removeConnection(key string, conn *websocket.Conn) { + connectionsMutex.Lock() + defer connectionsMutex.Unlock() + + remaining := Connections[key][:0] + for _, existing := range Connections[key] { + if existing != conn { + remaining = append(remaining, existing) + } + } + if len(remaining) == 0 { + delete(Connections, key) + return + } + Connections[key] = remaining +} + +// Broadcast writes to every viewer of a host/container, dropping any connection +// that errors or stalls. A viewer that never drains its socket would otherwise +// block the caller forever, which freezes ingestion for that container. +func Broadcast(key string, messageType int, payload []byte) { + for _, conn := range connectionsFor(key) { + conn.SetWriteDeadline(time.Now().Add(wsWriteDeadline)) + if err := conn.WriteMessage(messageType, payload); err != nil { + removeConnection(key, conn) + conn.Close() + } + } +} + +func ConnectionCount(key string) int { + connectionsMutex.RLock() + defer connectionsMutex.RUnlock() + return len(Connections[key]) +} diff --git a/application/backend/app/vars/connections_test.go b/application/backend/app/vars/connections_test.go new file mode 100644 index 0000000..d3e815f --- /dev/null +++ b/application/backend/app/vars/connections_test.go @@ -0,0 +1,108 @@ +package vars + +import ( + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func dialTestSocket(t *testing.T) (*websocket.Conn, func()) { + t.Helper() + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + + served := make(chan *websocket.Conn, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + served <- conn + })) + + url := "ws" + server.URL[len("http"):] + client, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + server.Close() + t.Fatalf("dial: %v", err) + } + + serverConn := <-served + return serverConn, func() { + client.Close() + serverConn.Close() + server.Close() + } +} + +func TestConnectionsSurviveConcurrentRegistrationAndBroadcast(t *testing.T) { + key := "racehost/racecontainer" + conn, cleanup := dialTestSocket(t) + defer cleanup() + + var wg sync.WaitGroup + stop := make(chan struct{}) + + // The websocket-upgrade handler registering viewers. + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + AddConnection(key+"-other", conn) + } + }() + + // The daemon goroutine fanning log lines out to viewers. + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + Broadcast(key, websocket.TextMessage, []byte("line")) + } + }() + + time.Sleep(150 * time.Millisecond) + close(stop) + wg.Wait() +} + +func TestBroadcastDropsAConnectionThatFailsToWrite(t *testing.T) { + key := "deadhost/deadcontainer" + conn, cleanup := dialTestSocket(t) + defer cleanup() + + AddConnection(key, conn) + if ConnectionCount(key) != 1 { + t.Fatalf("expected the connection to be registered, got %d", ConnectionCount(key)) + } + + conn.Close() + Broadcast(key, websocket.TextMessage, []byte("line")) + + if got := ConnectionCount(key); got != 0 { + t.Fatalf("a dead connection was kept after a failed write: %d still registered", got) + } +} + +func TestAddConnectionIgnoresNil(t *testing.T) { + key := "nilhost/nilcontainer" + AddConnection(key, nil) + + if got := ConnectionCount(key); got != 0 { + t.Fatalf("a nil connection was stored; a background goroutine will dereference it (%d stored)", got) + } + Broadcast(key, websocket.TextMessage, []byte("line")) +} diff --git a/application/backend/app/vars/state.go b/application/backend/app/vars/state.go new file mode 100644 index 0000000..f75540d --- /dev/null +++ b/application/backend/app/vars/state.go @@ -0,0 +1,90 @@ +package vars + +import "sync" + +var stateMutex sync.RWMutex + +func ActiveStreams() []string { + stateMutex.RLock() + defer stateMutex.RUnlock() + return append([]string{}, Active_Daemon_Streams...) +} + +func AddActiveStream(container string) { + stateMutex.Lock() + defer stateMutex.Unlock() + for _, existing := range Active_Daemon_Streams { + if existing == container { + return + } + } + Active_Daemon_Streams = append(Active_Daemon_Streams, container) +} + +func RemoveActiveStream(container string) { + stateMutex.Lock() + defer stateMutex.Unlock() + + remaining := make([]string, 0, len(Active_Daemon_Streams)) + for _, existing := range Active_Daemon_Streams { + if existing != container { + remaining = append(remaining, existing) + } + } + Active_Daemon_Streams = remaining +} + +func DockerContainerList() []string { + stateMutex.RLock() + defer stateMutex.RUnlock() + return append([]string{}, DockerContainers...) +} + +func SetDockerContainers(containers []string) { + stateMutex.Lock() + defer stateMutex.Unlock() + DockerContainers = append([]string{}, containers...) +} + +func AddDockerContainer(container string) { + stateMutex.Lock() + defer stateMutex.Unlock() + for _, existing := range DockerContainers { + if existing == container { + return + } + } + DockerContainers = append(DockerContainers, container) +} + +func SetAgentContainers(host string, containers []string) { + stateMutex.Lock() + defer stateMutex.Unlock() + AgentsActiveContainers[host] = append([]string{}, containers...) +} + +func AgentContainers(host string) []string { + stateMutex.RLock() + defer stateMutex.RUnlock() + return append([]string{}, AgentsActiveContainers[host]...) +} + +// QueueDelete records a container whose docker logs a remote agent should clear. +func QueueDelete(host string, container string) { + stateMutex.Lock() + defer stateMutex.Unlock() + ToDelete[host] = append(ToDelete[host], container) +} + +// TakeQueuedDeletes returns and clears the queue for a host. +func TakeQueuedDeletes(host string) []string { + stateMutex.Lock() + defer stateMutex.Unlock() + + queued := ToDelete[host] + if len(queued) == 0 { + return []string{} + } + delete(ToDelete, host) + return queued +} diff --git a/application/backend/app/vars/state_test.go b/application/backend/app/vars/state_test.go new file mode 100644 index 0000000..bc3c8ac --- /dev/null +++ b/application/backend/app/vars/state_test.go @@ -0,0 +1,62 @@ +package vars + +import ( + "sync" + "testing" + "time" +) + +func TestSharedStateSurvivesConcurrentAccess(t *testing.T) { + var wg sync.WaitGroup + stop := make(chan struct{}) + + writers := []func(){ + func() { AddActiveStream("container") }, + func() { RemoveActiveStream("container") }, + func() { AddDockerContainer("container") }, + func() { SetDockerContainers([]string{"a", "b"}) }, + func() { SetAgentContainers("host", []string{"a"}) }, + func() { QueueDelete("host", "container") }, + } + readers := []func(){ + func() { _ = ActiveStreams() }, + func() { _ = DockerContainerList() }, + func() { _ = AgentContainers("host") }, + func() { _ = TakeQueuedDeletes("host") }, + } + + for _, fn := range append(append([]func(){}, writers...), readers...) { + wg.Add(1) + go func(f func()) { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + f() + } + }(fn) + } + + time.Sleep(120 * time.Millisecond) + close(stop) + wg.Wait() +} + +func TestQueuedDeletesAreKeptPerHostAndDrainedOnce(t *testing.T) { + host := "queuehost" + _ = TakeQueuedDeletes(host) + + QueueDelete(host, "first") + QueueDelete(host, "second") + + queued := TakeQueuedDeletes(host) + if len(queued) != 2 || queued[0] != "first" || queued[1] != "second" { + t.Fatalf("queued deletions were dropped: %v", queued) + } + if again := TakeQueuedDeletes(host); len(again) != 0 { + t.Fatalf("the queue was not drained: %v", again) + } +} diff --git a/application/backend/app/vars/vars.go b/application/backend/app/vars/vars.go index cfc8fb6..dd68193 100644 --- a/application/backend/app/vars/vars.go +++ b/application/backend/app/vars/vars.go @@ -29,8 +29,9 @@ var ( Counters_For_Hosts_Last_30_Min = map[string]map[string]uint64{} Container_Stat_Counter = map[string]map[string]uint64{} - Mutex sync.Mutex - DBMutex sync.RWMutex + Mutex sync.Mutex + DBMutex sync.RWMutex + connectionsMutex sync.RWMutex FavsDB, FavsDBErr = leveldb.OpenFile("leveldb/favourites", nil) StateDB, StateDBErr = leveldb.OpenFile("leveldb/state", nil) diff --git a/application/backend/main.go b/application/backend/main.go index 6c30007..4e7844d 100644 --- a/application/backend/main.go +++ b/application/backend/main.go @@ -115,7 +115,7 @@ func main() { bgContext := context.Background() - if os.Getenv("AGENT") != "" { + if util.IsAgentMode() { streamController.StreamLogs(bgContext) } From eee85638a69d31c372f17bc001dfb552e75e93c5 Mon Sep 17 00:00:00 2001 From: Roman Malenko Date: Fri, 7 Aug 2026 22:03:49 +0300 Subject: [PATCH 05/31] fix(logs)!: stop dropping lines and remove the editHostname route --- .../app/containerdb/classifier_test.go | 36 ++++ .../backend/app/containerdb/containerdb.go | 86 +++++--- .../app/containerdb/containerdb_test.go | 21 +- .../backend/app/containerdb/cursor_test.go | 139 +++++++++++++ .../backend/app/containerdb/limit_test.go | 40 +++- application/backend/app/daemon/daemon.go | 184 ++++++++++++++---- .../backend/app/daemon/restart_test.go | 182 +++++++++++++++++ .../backend/app/docker/docker_client.go | 7 +- .../backend/app/routes/edituser_test.go | 116 +++++++++++ application/backend/app/routes/loginlimit.go | 31 ++- .../backend/app/routes/loginlimit_test.go | 120 ++++++++++++ application/backend/app/routes/routes.go | 73 +++---- application/backend/app/routes/routes_test.go | 7 + .../backend/app/routes/security_test.go | 29 ++- .../backend/app/statistics/keys_test.go | 99 ++++++++++ .../backend/app/statistics/registry.go | 38 +++- .../backend/app/statistics/statistics.go | 13 +- application/backend/app/util/jwt_test.go | 24 +++ application/backend/app/util/safename_test.go | 22 +++ application/backend/app/util/util.go | 58 +++++- application/backend/app/util/util_test.go | 6 + application/backend/app/vars/connections.go | 45 +++-- application/backend/app/vars/vars.go | 8 +- application/backend/main.go | 6 +- application/backend/main_test.go | 13 ++ .../Logs/LogsViewHeder/LogsViewHeder.test.mjs | 12 ++ .../Views/Logs/NewLogsV2.sharedlink.test.mjs | 113 +++++++++++ .../frontend/src/Views/Logs/NewLogsV2.svelte | 117 ++++++++--- .../src/Views/Logs/NewLogsV2.test.mjs | 61 ++++++ .../src/Views/Logs/classifier.test.mjs | 54 +++++ .../frontend/src/Views/Logs/functions.js | 145 +++++++------- .../src/Views/Logs/shareLinkViewState.js | 41 ++++ .../src/Views/Logs/timestamps.test.mjs | 61 ++++++ .../frontend/src/Views/Main/Main.svelte | 10 +- .../lib/ListWithChoise/ListWithChoise.svelte | 4 +- .../src/lib/SecretModal/SecretModal.svelte | 12 +- application/frontend/src/utils/fetch.js | 69 ++++--- application/frontend/src/utils/fetch.test.mjs | 74 +++++++ application/frontend/test/entry.js | 7 + 39 files changed, 1868 insertions(+), 315 deletions(-) create mode 100644 application/backend/app/containerdb/classifier_test.go create mode 100644 application/backend/app/containerdb/cursor_test.go create mode 100644 application/backend/app/daemon/restart_test.go create mode 100644 application/backend/app/routes/edituser_test.go create mode 100644 application/backend/app/routes/loginlimit_test.go create mode 100644 application/backend/app/statistics/keys_test.go create mode 100644 application/frontend/src/Views/Logs/NewLogsV2.sharedlink.test.mjs create mode 100644 application/frontend/src/Views/Logs/classifier.test.mjs create mode 100644 application/frontend/src/Views/Logs/timestamps.test.mjs create mode 100644 application/frontend/src/utils/fetch.test.mjs diff --git a/application/backend/app/containerdb/classifier_test.go b/application/backend/app/containerdb/classifier_test.go new file mode 100644 index 0000000..946b683 --- /dev/null +++ b/application/backend/app/containerdb/classifier_test.go @@ -0,0 +1,36 @@ +package containerdb + +import "testing" + +// Shared with frontend/src/Views/Logs/classifier.test.mjs: both implementations +// must agree on every one of these. +var classifierCases = []struct { + line string + level string +}{ + {"ERROR something failed", "error"}, + {"Error: connection refused", "error"}, + {"error: connection refused", "error"}, + {"ERR disk full", "error"}, + {"WARN low memory", "warn"}, + {"WARNING low memory", "warn"}, + {"warning low memory", "warn"}, + {"DEBUG entering loop", "debug"}, + {"debug entering loop", "debug"}, + {"INFO started", "info"}, + {"INFO ERROR_COUNT=0", "info"}, + {"ONLOGS: Container listening started!", "meta"}, + {"plain application output", "other"}, + {"", "other"}, + {"\x1b[31mERROR\x1b[0m red text", "error"}, + {"the word information appears here", "info"}, + {"2026-02-10 INFO ready", "info"}, +} + +func TestGetLogStatusKeyMatchesTheSharedRules(t *testing.T) { + for _, c := range classifierCases { + if got := GetLogStatusKey(c.line); got != c.level { + t.Errorf("GetLogStatusKey(%q) = %q, want %q", c.line, got, c.level) + } + } +} diff --git a/application/backend/app/containerdb/containerdb.go b/application/backend/app/containerdb/containerdb.go index 9bc7f21..9ef7254 100644 --- a/application/backend/app/containerdb/containerdb.go +++ b/application/backend/app/containerdb/containerdb.go @@ -17,17 +17,30 @@ import ( leveldbUtil "github.com/syndtr/goleveldb/leveldb/util" ) +// One classification rule, mirrored by classifyLogLine in +// frontend/src/Views/Logs/functions.js. The two used to disagree, so the status +// filter hid exactly the lines the UI painted red. +var logLevels = []struct { + needle string + level string +}{ + {"ERROR", "error"}, + {"ERR", "error"}, + {"WARNING", "warn"}, + {"WARN", "warn"}, + {"DEBUG", "debug"}, + {"INFO", "info"}, + {"ONLOGS", "meta"}, +} + func GetLogStatusKey(message string) string { - if strings.Contains(message, "ERROR") || strings.Contains(message, "ERR") { - return "error" - } else if strings.Contains(message, "WARN") || strings.Contains(message, "WARNING") { - return "warn" - } else if strings.Contains(message, "DEBUG") { - return "debug" - } else if strings.Contains(message, "INFO") { - return "info" - } else if strings.Contains(message, "ONLOGS") { - return "meta" + for _, token := range strings.Fields(ansiEscapeRegex.ReplaceAllString(message, "")) { + upper := strings.ToUpper(token) + for _, level := range logLevels { + if strings.Contains(upper, level.needle) { + return level.level + } + } } return "other" } @@ -298,7 +311,13 @@ func PutLogMessage(db *leveldb.DB, host string, container string, message_item [ countLogStatus(location, status_key) if statusesDB != nil { - statusesDB.Put([]byte(logKey), []byte(status_key), nil) + if err := statusesDB.Put([]byte(logKey), []byte(status_key), nil); err != nil { + // The handle may have been closed by a concurrent delete; without a + // retry the line is invisible to every severity filter, forever. + if reopened := util.GetDB(host, container, "statuses"); reopened != nil { + reopened.Put([]byte(logKey), []byte(status_key), nil) + } + } } err := db.Put([]byte(logKey), []byte(message_item[1]), nil) @@ -344,15 +363,25 @@ func getMoveDirection(getPrev bool, iter iterator.Iterator) func() bool { return func() bool { return iter.Next() } } -func searchInit(iter iterator.Iterator, startWith string) bool { - iter.Last() - - if startWith != "" { - if !iter.Seek([]byte(startWith)) { - return startWith > getDateTimeFromKey(string(iter.Key())) +// Positions the iterator for the requested direction. A failed Seek means no key +// is >= startWith: walking newer there is genuinely finished, while walking older +// should start from the newest row. goleveldb leaves a failed Seek at dirEOI, +// where Prev() silently jumps to Last() — so the direction must be explicit. +func searchInit(iter iterator.Iterator, startWith string, getPrev bool) bool { + if startWith == "" { + if getPrev { + return iter.First() } + return iter.Last() } - return true + + if iter.Seek([]byte(startWith)) { + return true + } + if getPrev { + return false + } + return iter.Last() } func getDateTimeFromKey(key string) string { @@ -397,7 +426,7 @@ func GetLogs(getPrev bool, include bool, host string, container string, message logs := [][]string{} move_direction := getMoveDirection(getPrev, iter) - if !searchInit(iter, startWith) { + if !searchInit(iter, startWith, getPrev) { to_return["is_end"] = true return to_return } @@ -405,6 +434,7 @@ func GetLogs(getPrev bool, include bool, host string, container string, message counter := 0 iteration := 0 last_processed_key := "" + last_visited_key := "" normalizedMessage := normalizeForSearch(message, caseSensetivity) hitScanCap := false for counter < limit { @@ -423,8 +453,11 @@ func GetLogs(getPrev bool, include bool, host string, container string, message } keyStr := string(key) + last_visited_key = keyStr timeStr := getDateTimeFromKey(keyStr) - if !include && (keyStr == startWith || timeStr == startWith) { + // Only the exact cursor row is skipped. Comparing the bare timestamp + // skipped every row sharing that millisecond. + if !include && keyStr == startWith { move_direction() continue } @@ -443,15 +476,20 @@ func GetLogs(getPrev bool, include bool, host string, container string, message continue } - logs = append(logs, []string{timeStr, value}) + logs = append(logs, []string{timeStr, value, keyStr}) increaseAndMove(&counter, move_direction) last_processed_key = keyStr } if hitScanCap { - // The scan was cut short, not exhausted. Reporting "more available" - // makes the client re-request the same page forever. - to_return["is_end"] = true + // Cut short rather than exhausted. is_end already reflects whether the + // iterator ran out, so only the cursor needs filling in: hand back the + // last key VISITED so the next request resumes past it. An empty cursor + // would restart the client at the newest row and loop forever. + to_return["scan_capped"] = true + if last_processed_key == "" { + last_processed_key = last_visited_key + } } to_return["logs"] = logs diff --git a/application/backend/app/containerdb/containerdb_test.go b/application/backend/app/containerdb/containerdb_test.go index b237598..fedc685 100644 --- a/application/backend/app/containerdb/containerdb_test.go +++ b/application/backend/app/containerdb/containerdb_test.go @@ -88,7 +88,26 @@ func TestGetLogs(t *testing.T) { t.Error("Invalid last logItem datetime: ", logs[4][0]) } - logs = GetLogs(true, false, "Test", "TestGetLogsCont", "", 30, vars.Year+"-02-10T12:51:09.230421754Z", false, nil)["logs"].([][]string) + // Rewritten: this used to pass a BARE TIMESTAMP as the cursor and assert that + // 4 of the 5 rows came back. That only held because GetLogs also skipped any + // row whose timestamp merely equalled the cursor, which silently drops every + // other row written in the same instant. Page with the cursor the API returns + // -- the full key -- which identifies exactly one row. + oldest := GetLogs(true, false, "Test", "TestGetLogsCont", "", 1, "", false, nil) + oldestRows := oldest["logs"].([][]string) + if len(oldestRows) != 1 { + t.Fatalf("expected one row when paging forward from the start, got %d", len(oldestRows)) + } + if oldestRows[0][0] != vars.Year+"-02-10T12:51:09.230421754Z" { + t.Error("Invalid first logItem datetime: ", oldestRows[0][0]) + } + + cursor := oldest["last_processed_key"].(string) + if cursor == "" { + t.Fatal("GetLogs returned no cursor to page with") + } + + logs = GetLogs(true, false, "Test", "TestGetLogsCont", "", 30, cursor, false, nil)["logs"].([][]string) if len(logs) != 4 { t.Error("4 logItems must be returned!") } diff --git a/application/backend/app/containerdb/cursor_test.go b/application/backend/app/containerdb/cursor_test.go new file mode 100644 index 0000000..1c4234c --- /dev/null +++ b/application/backend/app/containerdb/cursor_test.go @@ -0,0 +1,139 @@ +package containerdb + +import ( + "os" + "testing" + + "github.com/devforth/OnLogs/app/vars" + "github.com/syndtr/goleveldb/leveldb" +) + +const sameMillisecond = "-02-10T12:44:03.560000000Z" + +func seedSameMillisecondRows(t *testing.T, host, container string) []string { + t.Helper() + _ = os.RemoveAll("leveldb/hosts/" + host) + t.Cleanup(func() { _ = os.RemoveAll("leveldb/hosts/" + host) }) + + vars.Mutex.Lock() + delete(vars.Container_Stat_Counter, host+"/"+container) + vars.Mutex.Unlock() + + db, err := leveldb.OpenFile("leveldb/hosts/"+host+"/containers/"+container+"/logs", nil) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + messages := []string{"first in the millisecond", "second in the millisecond", "third in the millisecond"} + for _, message := range messages { + if err := PutLogMessage(db, host, container, []string{vars.Year + sameMillisecond, message}); err != nil { + t.Fatal(err) + } + } + return messages +} + +func rowKey(row []string) string { + if len(row) < 3 { + return "" + } + return row[2] +} + +// Pages through the whole container in one direction using the cursor the API +// returns, and reports every message it saw. +func pageEverything(t *testing.T, getPrev bool, host, container string, pageSize int) []string { + t.Helper() + + var seen []string + cursor := "" + for round := 0; round < 20; round++ { + result := GetLogs(getPrev, false, host, container, "", pageSize, cursor, false, nil) + rows := result["logs"].([][]string) + for _, row := range rows { + seen = append(seen, row[1]) + } + if len(rows) == 0 || result["is_end"] == true { + break + } + + next := result["last_processed_key"].(string) + if next == "" || next == cursor { + t.Fatalf("the cursor did not advance: %q", next) + } + cursor = next + } + return seen +} + +func assertEachExactlyOnce(t *testing.T, label string, seen []string, expected []string) { + t.Helper() + + counts := map[string]int{} + for _, message := range seen { + counts[message]++ + } + for _, message := range expected { + switch counts[message] { + case 0: + t.Errorf("%s: row %q was never returned", label, message) + case 1: + default: + t.Errorf("%s: row %q was returned %d times", label, message, counts[message]) + } + } + if len(seen) != len(expected) { + t.Errorf("%s: paged %d rows, expected %d: %v", label, len(seen), len(expected), seen) + } +} + +func TestSameMillisecondRowsPageExactlyOnceInBothDirections(t *testing.T) { + host, container := "CursorHost", "CursorContainer" + messages := seedSameMillisecondRows(t, host, container) + + // One row per page forces the cursor to land inside the millisecond group. + backwards := pageEverything(t, false, host, container, 1) + assertEachExactlyOnce(t, "newest-first", backwards, messages) + + forwards := pageEverything(t, true, host, container, 1) + assertEachExactlyOnce(t, "oldest-first", forwards, messages) +} + +func TestGetLogsReturnsAStableRowIdentity(t *testing.T) { + host, container := "IdentityHost", "IdentityContainer" + seedSameMillisecondRows(t, host, container) + + rows := GetLogs(false, false, host, container, "", 30, "", false, nil)["logs"].([][]string) + if len(rows) != 3 { + t.Fatalf("expected 3 rows, got %d", len(rows)) + } + + keys := map[string]struct{}{} + for _, row := range rows { + key := rowKey(row) + if key == "" { + t.Fatalf("row %v carries no stable key, so the client cannot deduplicate or page precisely", row) + } + if _, exists := keys[key]; exists { + t.Fatalf("two rows share the key %q", key) + } + keys[key] = struct{}{} + } +} + +// A cursor newer than every stored row must return nothing, not teleport the +// iterator back to the newest page. +func TestGetLogsWithAStaleFutureCursorReturnsNothing(t *testing.T) { + host, container := "StaleCursorHost", "StaleCursorContainer" + seedSameMillisecondRows(t, host, container) + + result := GetLogs(true, false, host, container, "", 30, "2999-01-01T00:00:00.000000000Z", false, nil) + rows := result["logs"].([][]string) + if len(rows) != 0 { + t.Fatalf("a cursor newer than every row returned %d rows; the iterator teleported back to the newest page: %v", len(rows), rows) + } + if result["is_end"] != true { + t.Errorf("expected is_end=true for an exhausted forward scan, got %v", result["is_end"]) + } +} diff --git a/application/backend/app/containerdb/limit_test.go b/application/backend/app/containerdb/limit_test.go index 5845ab4..67022b0 100644 --- a/application/backend/app/containerdb/limit_test.go +++ b/application/backend/app/containerdb/limit_test.go @@ -74,16 +74,46 @@ func TestGetLogsClampsAnEnormousLimit(t *testing.T) { } } -func TestGetLogsReportsEndWhenTheScanCapStopsIt(t *testing.T) { - seedLogs(t, "CapHost", "CapCont", 50) +// A capped scan must hand back a cursor that advances. Reporting is_end would +// make everything past the cap unreachable; an empty cursor would restart the +// client at the newest row and loop forever. +func TestGetLogsResumesPastTheScanCap(t *testing.T) { + seedLogs(t, "CapHost", "CapCont", 60) original := maxScanIterations maxScanIterations = 10 t.Cleanup(func() { maxScanIterations = original }) - result := GetLogs(false, false, "CapHost", "CapCont", "no-such-text-anywhere", 30, "", false, nil) + seen := map[string]int{} + cursor := "" + for round := 0; round < 40; round++ { + // "line 0" matches exactly one row, the OLDEST — far beyond the first cap + // window, since the scan walks newest-first. + result := GetLogs(false, false, "CapHost", "CapCont", "line 0", 30, cursor, false, nil) - if result["is_end"] != true { - t.Fatalf("the scan stopped at the iteration cap but reported is_end=%v; the client re-requests the same page forever", result["is_end"]) + for _, row := range result["logs"].([][]string) { + seen[row[1]]++ + } + if result["is_end"] == true { + break + } + + next := result["last_processed_key"].(string) + if next == "" { + t.Fatalf("round %d: a capped scan returned no cursor, so the client restarts at the newest row", round) + } + if next == cursor { + t.Fatalf("round %d: the cursor did not advance past the cap (%q)", round, next) + } + cursor = next + } + + if len(seen) == 0 { + t.Fatal("a rare search term past the scan cap was never reachable") + } + for message, count := range seen { + if count != 1 { + t.Errorf("row %q was returned %d times across the capped scan", message, count) + } } } diff --git a/application/backend/app/daemon/daemon.go b/application/backend/app/daemon/daemon.go index 8cc47cc..f586dcf 100644 --- a/application/backend/app/daemon/daemon.go +++ b/application/backend/app/daemon/daemon.go @@ -22,6 +22,7 @@ import ( "github.com/docker/docker/pkg/stdcopy" "github.com/gorilla/websocket" "github.com/syndtr/goleveldb/leveldb" + leveldbUtil "github.com/syndtr/goleveldb/leveldb/util" ) const ( @@ -41,6 +42,7 @@ type DaemonService struct { streamSeq uint64 recentFingerprints map[string][]string recentSet map[string]map[string]struct{} + droppedReplays map[string]int } func (h *DaemonService) ensureRuntimeState() { @@ -59,6 +61,9 @@ func (h *DaemonService) ensureRuntimeState() { if h.recentSet == nil { h.recentSet = map[string]map[string]struct{}{} } + if h.droppedReplays == nil { + h.droppedReplays = map[string]int{} + } } func createLogMessage(db *leveldb.DB, host string, container string, message string) string { @@ -107,15 +112,12 @@ func parseDockerLogLine(line string) ([]string, time.Time, bool) { return []string{tsStr, parts[1]}, ts, true } -func (h *DaemonService) isRecentDuplicate(containerName, fingerprint string) bool { - h.streamsMu.Lock() - defer h.streamsMu.Unlock() - +func (h *DaemonService) rememberFingerprint(containerName, fingerprint string) { if h.recentSet[containerName] == nil { h.recentSet[containerName] = map[string]struct{}{} } if _, exists := h.recentSet[containerName][fingerprint]; exists { - return true + return } h.recentSet[containerName][fingerprint] = struct{}{} @@ -125,10 +127,129 @@ func (h *DaemonService) isRecentDuplicate(containerName, fingerprint string) boo h.recentFingerprints[containerName] = h.recentFingerprints[containerName][1:] delete(h.recentSet[containerName], toDrop) } +} + +// forgetContainer releases the dedupe state for a container whose stream ended. +func (h *DaemonService) forgetContainer(containerName string) { + h.streamsMu.Lock() + defer h.streamsMu.Unlock() + delete(h.recentSet, containerName) + delete(h.recentFingerprints, containerName) + delete(h.droppedReplays, containerName) +} + +// seedBoundaryFingerprints loads the lines already stored at exactly the cursor +// timestamp, so a replay of them is recognised while a genuinely new line +// sharing that nanosecond is still kept. +func (h *DaemonService) seedBoundaryFingerprints(host, containerName string, cursor time.Time) { + if cursor.IsZero() { + return + } + db := util.GetDB(host, containerName, "logs") + if db == nil { + return + } + + prefix := cursor.UTC().Format(streamTimestampFmt) + iter := db.NewIterator(leveldbUtil.BytesPrefix([]byte(prefix)), nil) + defer iter.Release() + + h.streamsMu.Lock() + defer h.streamsMu.Unlock() + for iter.Next() { + h.rememberFingerprint(containerName, prefix+" "+string(iter.Value())) + } +} + +func (h *DaemonService) countDroppedReplay(containerName string) { + h.streamsMu.Lock() + defer h.streamsMu.Unlock() + h.droppedReplays[containerName]++ + if count := h.droppedReplays[containerName]; count == 1 || count%100 == 0 { + fmt.Printf("INFO: dropped %d replayed log line(s) for %s\n", count, containerName) + } +} + +func (h *DaemonService) DroppedReplays(containerName string) int { + h.streamsMu.Lock() + defer h.streamsMu.Unlock() + return h.droppedReplays[containerName] +} + +func (h *DaemonService) isRecentDuplicate(containerName, fingerprint string) bool { + h.streamsMu.Lock() + defer h.streamsMu.Unlock() + + if h.recentSet[containerName] == nil { + h.recentSet[containerName] = map[string]struct{}{} + } + if _, exists := h.recentSet[containerName][fingerprint]; exists { + return true + } + h.rememberFingerprint(containerName, fingerprint) return false } +// storedThrough is the cursor as persisted: the timestamp of the newest line +// already stored. getResumeSince deliberately rewinds behind it, so everything +// the stream replays up to this point is already on disk. +func (h *DaemonService) storedThrough(host, containerName string) time.Time { + db := util.GetDB(host, containerName, "streamstate") + if db == nil { + return time.Time{} + } + raw, err := db.Get([]byte(cursorKey), nil) + if err != nil || len(raw) == 0 { + return time.Time{} + } + ts, err := time.Parse(time.RFC3339Nano, string(raw)) + if err != nil { + return time.Time{} + } + return ts +} + +// ingestLine stores one streamed line, dropping anything the previous run had +// already persisted. Returns true when the line was stored. +func (h *DaemonService) ingestLine(host, containerName string, currentDB *leveldb.DB, token string, toHost bool, storedThrough time.Time, line string) bool { + h.ensureRuntimeState() + + logItem, cursorTS, ok := parseDockerLogLine(line) + if !ok { + return false + } + + // Docker's Since is floored to whole seconds and the cursor is rewound on + // purpose, so every attach replays lines that are already stored. + if !storedThrough.IsZero() && cursorTS.Before(storedThrough) { + h.countDroppedReplay(containerName) + return false + } + + fingerprint := logItem[0] + " " + logItem[1] + if h.isRecentDuplicate(containerName, fingerprint) { + h.countDroppedReplay(containerName) + return false + } + + if toHost { + agent.SendLogMessage(token, containerName, logItem) + h.saveCursor(host, containerName, cursorTS) + return true + } + + if err := containerdb.PutLogMessage(currentDB, host, containerName, logItem); err != nil { + fmt.Println("ERROR:", err.Error()) + return false + } + h.saveCursor(host, containerName, cursorTS) + + toSend, _ := json.Marshal(logItem) + vars.Broadcast(containerName, websocket.TextMessage, toSend) + return true +} + func (h *DaemonService) getResumeSince(host, containerName string) time.Time { db := util.GetDB(host, containerName, "streamstate") if db == nil { @@ -230,6 +351,8 @@ func (h *DaemonService) finalizeStream(containerName string, streamID uint64) bo func (h *DaemonService) runContainerStream(ctx context.Context, containerName string, toHost bool, streamID uint64) { host := util.GetHost() + storedThrough := h.storedThrough(host, containerName) + h.seedBoundaryFingerprints(host, containerName, storedThrough) since := h.getResumeSince(host, containerName) rc, err := h.DockerClient.Client.ContainerLogs( ctx, @@ -265,31 +388,7 @@ func (h *DaemonService) runContainerStream(ctx context.Context, containerName st } streamErr := h.streamDockerLogs(ctx, rc, func(line string) { - logItem, cursorTS, ok := parseDockerLogLine(line) - if !ok { - return - } - - fingerprint := logItem[0] + " " + logItem[1] - if h.isRecentDuplicate(containerName, fingerprint) { - return - } - - if toHost { - agent.SendLogMessage(token, containerName, logItem) - h.saveCursor(host, containerName, cursorTS) - return - } - - err := containerdb.PutLogMessage(currentDB, host, containerName, logItem) - if err != nil { - fmt.Println("ERROR:", err.Error()) - return - } - h.saveCursor(host, containerName, cursorTS) - - toSend, _ := json.Marshal(logItem) - vars.Broadcast(containerName, websocket.TextMessage, toSend) + h.ingestLine(host, containerName, currentDB, token, toHost, storedThrough, line) }, !h.isContainerTTY(ctx, containerName)) if streamErr != nil && ctx.Err() == nil { @@ -360,6 +459,18 @@ func (h *DaemonService) CreateDaemonToDBStream(ctx context.Context, containerNam h.runContainerStream(ctx, containerName, false, 0) } +// runningNames keeps only containers docker reports as running. Attaching to an +// exited container writes a started/stopped META pair on every reconcile. +func runningNames(result []docker.ContainerNamesResult) []string { + var names []string + for i := range result { + if result[i].Running && result[i].Name != "" { + names = append(names, result[i].Name) + } + } + return names +} + // returns list of names of docker containers from docker daemon func (h *DaemonService) GetContainersList(ctx context.Context) []string { result, err := h.DockerClient.GetContainerNames(ctx) @@ -370,24 +481,21 @@ func (h *DaemonService) GetContainersList(ctx context.Context) []string { var names []string - containersMetaDB := vars.ContainersMeta_DBs[util.GetHost()] + containersMetaDB := util.GetContainersMetaDB(util.GetHost()) if containersMetaDB == nil { - containersMetaDB, err := leveldb.OpenFile("leveldb/hosts/"+util.GetHost()+"/containersMeta", nil) - if err != nil { - panic(err) - } - vars.ContainersMeta_DBs[util.GetHost()] = containersMetaDB + return runningNames(result) } - containersMetaDB = vars.ContainersMeta_DBs[util.GetHost()] for i := range result { name := result[i].Name id := result[i].ID - names = append(names, name) + // Every container is recorded, so a stopped one can still be looked up + // by name; only running ones are streamed and shown as enabled. containersMetaDB.Put([]byte(name), []byte(id), nil) } + names = runningNames(result) return names } diff --git a/application/backend/app/daemon/restart_test.go b/application/backend/app/daemon/restart_test.go new file mode 100644 index 0000000..248ecf7 --- /dev/null +++ b/application/backend/app/daemon/restart_test.go @@ -0,0 +1,182 @@ +package daemon + +import ( + "fmt" + "os" + "testing" + "time" + + "github.com/devforth/OnLogs/app/docker" + "github.com/devforth/OnLogs/app/util" + "github.com/devforth/OnLogs/app/vars" +) + +// One docker log line as the daemon receives it: RFC3339Nano timestamp, a space, +// then the message. +func corpusLine(index int) string { + ts := time.Date(2026, 2, 10, 12, 44, 3, index*1000000, time.UTC) + return ts.Format(time.RFC3339Nano) + fmt.Sprintf(" line-%03d", index) +} + +func storedRowCount(t *testing.T, host, container string) int { + t.Helper() + db := util.GetDB(host, container, "logs") + if db == nil { + t.Fatal("no logs database") + } + iter := db.NewIterator(nil, nil) + defer iter.Release() + + count := 0 + for iter.Next() { + count++ + } + return count +} + +// A container's stream is attached, lines are ingested, the process dies, and a +// new process attaches again. Docker's Since is floored to whole seconds and the +// resume cursor is deliberately rewound, so the second attach replays lines that +// are already stored. The in-memory dedupe ring does not survive the restart. +func TestRestartMidStreamStoresEachLineExactlyOnce(t *testing.T) { + host := util.GetHost() + container := "RestartUnderLoad" + _ = os.RemoveAll("leveldb/hosts/" + host + "/containers/" + container) + t.Cleanup(func() { _ = os.RemoveAll("leveldb/hosts/" + host + "/containers/" + container) }) + + vars.Mutex.Lock() + delete(vars.Container_Stat_Counter, host+"/"+container) + vars.Mutex.Unlock() + + const totalLines = 60 + const restartAfter = 40 + const replayFrom = 20 // the overlap window the rewound cursor re-serves + + emitted := map[string]struct{}{} + + // ---- first process ---- + first := &DaemonService{} + first.ensureRuntimeState() + db := util.GetDB(host, container, "logs") + if db == nil { + t.Fatal("could not open the logs database") + } + + storedThrough := first.storedThrough(host, container) + first.seedBoundaryFingerprints(host, container, storedThrough) + for i := 0; i < restartAfter; i++ { + line := corpusLine(i) + emitted[line] = struct{}{} + first.ingestLine(host, container, db, "", false, storedThrough, line) + } + + if got := storedRowCount(t, host, container); got != restartAfter { + t.Fatalf("first run stored %d rows, expected %d", got, restartAfter) + } + + // ---- process dies and restarts: fresh service, empty in-memory ring ---- + second := &DaemonService{} + second.ensureRuntimeState() + + restartCursor := second.storedThrough(host, container) + if restartCursor.IsZero() { + t.Fatal("no cursor was persisted, so a restart cannot know where it left off") + } + second.seedBoundaryFingerprints(host, container, restartCursor) + + // The rewound stream replays from an earlier point, then continues. + for i := replayFrom; i < totalLines; i++ { + line := corpusLine(i) + emitted[line] = struct{}{} + second.ingestLine(host, container, db, "", false, restartCursor, line) + } + + stored := storedRowCount(t, host, container) + if stored != len(emitted) { + t.Fatalf("stored %d rows for %d distinct emitted lines: %d duplicate row(s) survived the restart", + stored, len(emitted), stored-len(emitted)) + } + if stored != totalLines { + t.Fatalf("stored %d rows, expected %d", stored, totalLines) + } + + if dropped := second.DroppedReplays(container); dropped != restartAfter-replayFrom { + t.Errorf("expected %d replayed lines to be dropped and counted, got %d", + restartAfter-replayFrom, dropped) + } + + assertNoDuplicateMessages(t, host, container) +} + +func assertNoDuplicateMessages(t *testing.T, host, container string) { + t.Helper() + db := util.GetDB(host, container, "logs") + iter := db.NewIterator(nil, nil) + defer iter.Release() + + seen := map[string]struct{}{} + for iter.Next() { + message := string(iter.Value()) + if _, exists := seen[message]; exists { + t.Errorf("message stored more than once: %q", message) + } + seen[message] = struct{}{} + } +} + +// Two genuinely distinct lines can share the cursor's exact nanosecond. The +// replay guard must not silently discard the second one. +func TestRestartKeepsADistinctLineSharingTheCursorTimestamp(t *testing.T) { + host := util.GetHost() + container := "SameNanosecondBoundary" + _ = os.RemoveAll("leveldb/hosts/" + host + "/containers/" + container) + t.Cleanup(func() { _ = os.RemoveAll("leveldb/hosts/" + host + "/containers/" + container) }) + + db := util.GetDB(host, container, "logs") + if db == nil { + t.Fatal("could not open the logs database") + } + + ts := time.Date(2026, 2, 10, 12, 44, 3, 123456789, time.UTC).Format(time.RFC3339Nano) + first := &DaemonService{} + first.ensureRuntimeState() + + if !first.ingestLine(host, container, db, "", false, time.Time{}, ts+" first at this nanosecond") { + t.Fatal("the first line was not stored") + } + + // Restart: the cursor now sits exactly on that nanosecond. + second := &DaemonService{} + second.ensureRuntimeState() + cursor := second.storedThrough(host, container) + second.seedBoundaryFingerprints(host, container, cursor) + + // The replay of the stored line must be dropped... + if second.ingestLine(host, container, db, "", false, cursor, ts+" first at this nanosecond") { + t.Error("a replayed line at the cursor timestamp was stored again") + } + // ...but a genuinely different line at the same nanosecond must be kept. + if !second.ingestLine(host, container, db, "", false, cursor, ts+" second at this nanosecond") { + t.Error("a distinct line sharing the cursor's nanosecond was silently discarded") + } + + assertNoDuplicateMessages(t, host, container) + if got := storedRowCount(t, host, container); got != 2 { + t.Fatalf("expected both distinct lines to be stored, got %d rows", got) + } +} + +func TestRunningNamesSkipsExitedContainers(t *testing.T) { + result := []docker.ContainerNamesResult{ + {Name: "alive", ID: "1", Running: true}, + {Name: "exited", ID: "2", Running: false}, + {Name: "", ID: "3", Running: true}, + {Name: "alive-too", ID: "4", Running: true}, + } + + names := runningNames(result) + + if len(names) != 2 || names[0] != "alive" || names[1] != "alive-too" { + t.Fatalf("expected only the running, named containers, got %v", names) + } +} diff --git a/application/backend/app/docker/docker_client.go b/application/backend/app/docker/docker_client.go index c4499f0..21e7788 100644 --- a/application/backend/app/docker/docker_client.go +++ b/application/backend/app/docker/docker_client.go @@ -15,8 +15,9 @@ type DockerService struct { } type ContainerNamesResult struct { - Name string - ID string + Name string + ID string + Running bool } func (s *DockerService) GetContainerNames(ctx context.Context) ([]ContainerNamesResult, error) { @@ -31,7 +32,7 @@ func (s *DockerService) GetContainerNames(ctx context.Context) ([]ContainerNames if len(c.Names) > 0 { name = strings.TrimPrefix(c.Names[0], "/") } - res = append(res, ContainerNamesResult{Name: name, ID: c.ID}) + res = append(res, ContainerNamesResult{Name: name, ID: c.ID, Running: c.State == "running"}) } return res, nil } diff --git a/application/backend/app/routes/edituser_test.go b/application/backend/app/routes/edituser_test.go new file mode 100644 index 0000000..6f32e47 --- /dev/null +++ b/application/backend/app/routes/edituser_test.go @@ -0,0 +1,116 @@ +package routes + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/devforth/OnLogs/app/userdb" + "github.com/devforth/OnLogs/app/util" + "github.com/devforth/OnLogs/app/vars" +) + +func TestEditUserActuallyChangesThePassword(t *testing.T) { + ctrl := initTestConfig() + os.Setenv("ADMIN_USERNAME", "admin") + + userdb.CreateUser("rotateme", "the-old-password") + t.Cleanup(func() { userdb.DeleteUser("rotateme", "") }) + + body, _ := json.Marshal(map[string]string{"login": "rotateme", "password": "the-new-password"}) + req, _ := http.NewRequest("POST", "/api/v1/editUser", bytes.NewBuffer(body)) + req.AddCookie(&http.Cookie{Name: "onlogs-cookie", Value: util.CreateJWT("admin")}) + rr := httptest.NewRecorder() + http.HandlerFunc(ctrl.EditUser).ServeHTTP(rr, req) + + if code := rr.Result().StatusCode; code != http.StatusOK { + t.Fatalf("editUser returned %d: %s", code, rr.Body.String()) + } + + var response map[string]interface{} + json.Unmarshal(rr.Body.Bytes(), &response) + if response["error"] != nil { + t.Fatalf("editUser reported an error: %v", response["error"]) + } + + // The UI shows "Password was changed" on this response, so it had better be true. + if !userdb.CheckUserPassword("rotateme", "the-new-password") { + t.Error("editUser reported success but the new password does not work") + } + if userdb.CheckUserPassword("rotateme", "the-old-password") { + t.Error("editUser reported success but the old password still works") + } +} + +func TestEditUserRejectsAnEmptyPassword(t *testing.T) { + ctrl := initTestConfig() + os.Setenv("ADMIN_USERNAME", "admin") + + userdb.CreateUser("emptyrotate", "a-real-password") + t.Cleanup(func() { userdb.DeleteUser("emptyrotate", "") }) + + body, _ := json.Marshal(map[string]string{"login": "emptyrotate", "password": ""}) + req, _ := http.NewRequest("POST", "/api/v1/editUser", bytes.NewBuffer(body)) + req.AddCookie(&http.Cookie{Name: "onlogs-cookie", Value: util.CreateJWT("admin")}) + rr := httptest.NewRecorder() + http.HandlerFunc(ctrl.EditUser).ServeHTTP(rr, req) + + if userdb.CheckUserPassword("emptyrotate", "") { + t.Fatal("an empty password was accepted") + } + if !userdb.CheckUserPassword("emptyrotate", "a-real-password") { + t.Error("the original password stopped working") + } +} + +// Favourites are stored as "/", but GetHosts looked every one up +// under the LOCAL hostname, so stars vanished on remote hosts and appeared on +// containers that were never starred. +func TestGetHostsReadsFavouritesPerHost(t *testing.T) { + ctrl := initTestConfig() + + os.RemoveAll("leveldb/hosts") + os.MkdirAll("leveldb/hosts/FavHostA/containers/shared", 0o700) + os.MkdirAll("leveldb/hosts/FavHostB/containers/shared", 0o700) + t.Cleanup(func() { + vars.FavsDB.Delete([]byte("FavHostB/shared"), nil) + os.RemoveAll("leveldb/hosts") + }) + + if err := vars.FavsDB.Put([]byte("FavHostB/shared"), nil, nil); err != nil { + t.Fatal(err) + } + + req, _ := http.NewRequest("GET", "/api/v1/getHosts", nil) + req.AddCookie(&http.Cookie{Name: "onlogs-cookie", Value: util.CreateJWT("someuser")}) + rr := httptest.NewRecorder() + http.HandlerFunc(ctrl.GetHosts).ServeHTTP(rr, req) + + var hosts []struct { + Host string `json:"host"` + Services []struct { + ServiceName string `json:"serviceName"` + IsFavorite bool `json:"isFavorite"` + } `json:"services"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &hosts); err != nil { + t.Fatalf("unmarshal: %v -- body %s", err, rr.Body.String()) + } + + favourites := map[string]bool{} + for _, host := range hosts { + for _, service := range host.Services { + favourites[host.Host+"/"+service.ServiceName] = service.IsFavorite + } + } + + if !favourites["FavHostB/shared"] { + t.Error("the starred container on FavHostB is not reported as a favourite") + } + if favourites["FavHostA/shared"] { + t.Error("a container that was never starred on FavHostA is reported as a favourite") + } +} diff --git a/application/backend/app/routes/loginlimit.go b/application/backend/app/routes/loginlimit.go index 892d817..a23f439 100644 --- a/application/backend/app/routes/loginlimit.go +++ b/application/backend/app/routes/loginlimit.go @@ -1,6 +1,8 @@ package routes import ( + "crypto/sha256" + "encoding/hex" "net" "net/http" "sync" @@ -10,6 +12,8 @@ import ( const ( freeLoginAttempts = 5 maxLoginBackoff = 15 * time.Minute + loginAttemptTTL = time.Hour + maxLoginEntries = 4096 ) type loginAttempts struct { @@ -20,6 +24,7 @@ type loginAttempts struct { type loginAttempt struct { failures int blocked time.Time + seen time.Time } var loginLimiter = &loginAttempts{entries: map[string]*loginAttempt{}} @@ -35,12 +40,18 @@ func backoffFor(failures int) time.Duration { return backoff } +// Keys are bounded in size: the login half is attacker-chosen and can be up to +// the whole request body. +func loginKey(addr string, login string) string { + sum := sha256.Sum256([]byte(login)) + return addr + "|" + hex.EncodeToString(sum[:8]) +} + func (l *loginAttempts) allow(keys ...string) bool { now := time.Now() l.mu.Lock() defer l.mu.Unlock() - l.prune(now) for _, key := range keys { if entry, ok := l.entries[key]; ok && now.Before(entry.blocked) { @@ -64,6 +75,7 @@ func (l *loginAttempts) fail(keys ...string) { l.entries[key] = entry } entry.failures++ + entry.seen = now if backoff := backoffFor(entry.failures); backoff > 0 { entry.blocked = now.Add(backoff) } @@ -78,12 +90,21 @@ func (l *loginAttempts) succeed(keys ...string) { } } +// Called only from fail(), so the scan cost is bounded by the failure rate +// rather than paid on every login. func (l *loginAttempts) prune(now time.Time) { for key, entry := range l.entries { - if entry.blocked.IsZero() || now.After(entry.blocked.Add(maxLoginBackoff)) { - if entry.failures <= freeLoginAttempts && entry.blocked.IsZero() { - continue - } + if now.Sub(entry.seen) > loginAttemptTTL && now.After(entry.blocked) { + delete(l.entries, key) + } + } + + if len(l.entries) < maxLoginEntries { + return + } + // Hard ceiling: drop everything that is not currently blocking anyone. + for key, entry := range l.entries { + if now.After(entry.blocked) { delete(l.entries, key) } } diff --git a/application/backend/app/routes/loginlimit_test.go b/application/backend/app/routes/loginlimit_test.go new file mode 100644 index 0000000..eec0102 --- /dev/null +++ b/application/backend/app/routes/loginlimit_test.go @@ -0,0 +1,120 @@ +package routes + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/devforth/OnLogs/app/userdb" +) + +func TestLoginLimiterCannotBeUsedToLockOutAnAccount(t *testing.T) { + limiter := &loginAttempts{entries: map[string]*loginAttempt{}} + + attacker := "203.0.113.9" + victim := "198.51.100.7" + + // The attacker guesses at the admin account from their own address. + for i := 0; i < 50; i++ { + limiter.fail("ip:"+attacker, "pair:"+loginKey(attacker, "admin")) + } + + if limiter.allow("ip:" + attacker) { + t.Error("the attacker was not throttled") + } + if !limiter.allow("ip:"+victim, "pair:"+loginKey(victim, "admin")) { + t.Fatal("an attacker guessing at an account locked the real owner out of it") + } +} + +func TestLoginLimiterDoesNotGrowWithoutBound(t *testing.T) { + limiter := &loginAttempts{entries: map[string]*loginAttempt{}} + + // One failed attempt per distinct username, as an unauthenticated attacker + // can produce at will. + for i := 0; i < maxLoginEntries*3; i++ { + name := "user-" + strconv.Itoa(i) + limiter.fail("pair:" + loginKey("203.0.113.9", name)) + } + + limiter.mu.Lock() + size := len(limiter.entries) + limiter.mu.Unlock() + + if size > maxLoginEntries { + t.Fatalf("the limiter retained %d entries for %d attempts; memory grows without bound", + size, maxLoginEntries*3) + } +} + +func TestLoginLimiterKeysAreBounded(t *testing.T) { + huge := make([]byte, 900*1024) + for i := range huge { + huge[i] = 'a' + } + if got := len(loginKey("203.0.113.9", string(huge))); got > 64 { + t.Fatalf("a %d-byte login produced a %d-byte key", len(huge), got) + } +} + +func TestLoginLimiterStillThrottlesRepeatedFailures(t *testing.T) { + limiter := &loginAttempts{entries: map[string]*loginAttempt{}} + key := "pair:" + loginKey("203.0.113.9", "someone") + + for i := 0; i < freeLoginAttempts; i++ { + if !limiter.allow(key) { + t.Fatalf("throttled after only %d attempts", i) + } + limiter.fail(key) + } + limiter.fail(key) + + if limiter.allow(key) { + t.Fatal("repeated failures were not throttled") + } + if backoffFor(100) != maxLoginBackoff { + t.Errorf("backoff did not clamp: %v", backoffFor(100)) + } + if backoffFor(freeLoginAttempts) != 0 { + t.Errorf("throttled inside the free allowance: %v", backoffFor(freeLoginAttempts)) + } + _ = time.Second +} + +// The lockout lived in which key the handler chose, so it has to be exercised +// through the handler. +func TestLoginLockoutCannotBeInflictedOnAnotherUser(t *testing.T) { + ctrl := initTestConfig() + userdb.CreateUser("victimaccount", "the-real-password") + t.Cleanup(func() { userdb.DeleteUser("victimaccount", "") }) + + loginLimiter.mu.Lock() + loginLimiter.entries = map[string]*loginAttempt{} + loginLimiter.mu.Unlock() + + attempt := func(addr, password string) int { + body, _ := json.Marshal(map[string]string{"Login": "victimaccount", "Password": password}) + req, _ := http.NewRequest("POST", "/api/v1/login", bytes.NewBuffer(body)) + req.RemoteAddr = addr + ":40000" + rr := httptest.NewRecorder() + http.HandlerFunc(ctrl.Login).ServeHTTP(rr, req) + return rr.Result().StatusCode + } + + // An attacker guesses at the account from their own address. + for i := 0; i < 20; i++ { + attempt("203.0.113.9", "guess-"+strconv.Itoa(i)) + } + if code := attempt("203.0.113.9", "the-real-password"); code != http.StatusTooManyRequests { + t.Errorf("the attacker was not throttled: status %d", code) + } + + // The real owner, from their own address, must still be able to log in. + if code := attempt("198.51.100.7", "the-real-password"); code != http.StatusOK { + t.Fatalf("an attacker guessing at the account locked its real owner out: status %d", code) + } +} diff --git a/application/backend/app/routes/routes.go b/application/backend/app/routes/routes.go index 5d2b0fc..3e81dde 100644 --- a/application/backend/app/routes/routes.go +++ b/application/backend/app/routes/routes.go @@ -322,7 +322,7 @@ func (h *RouteController) GetHosts(w http.ResponseWriter, req *http.Request) { Services []map[string]interface{} `json:"services"` } - var to_return []HostsList + to_return := []HostsList{} ctx := req.Context() activeContainers := h.DaemonService.GetContainersList(ctx) @@ -331,7 +331,7 @@ func (h *RouteController) GetHosts(w http.ResponseWriter, req *http.Request) { containers, _ := os.ReadDir("leveldb/hosts/" + host.Name() + "/containers") allContainers := []map[string]interface{}{} for _, container := range containers { - isFavorite, _ := vars.FavsDB.Has([]byte(util.GetHost()+"/"+container.Name()), nil) + isFavorite, _ := vars.FavsDB.Has([]byte(host.Name()+"/"+container.Name()), nil) if util.Contains(container.Name(), activeContainers) || util.Contains(container.Name(), vars.AgentContainers(host.Name())) { allContainers = append(allContainers, map[string]interface{}{"serviceName": container.Name(), "isDisabled": false, "isFavorite": isFavorite}) } else { @@ -457,6 +457,14 @@ func (h *RouteController) GetStorageData(w http.ResponseWriter, req *http.Reques json.NewEncoder(w).Encode(util.GetStorageData()) } +func statusFilter(params url.Values) *string { + status := params.Get("status") + if status == "" { + return nil + } + return &status +} + func (h *RouteController) GetPrevLogs(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyUser(&w, req) { return @@ -478,7 +486,7 @@ func (h *RouteController) GetPrevLogs(w http.ResponseWriter, req *http.Request) if params.Get("host") == "" { panic("Host is not mentioned!") } - json.NewEncoder(w).Encode(containerdb.GetLogs(true, false, params.Get("host"), params.Get("id"), params.Get("search"), limit, params.Get("startWith"), caseSensetive, nil)) + json.NewEncoder(w).Encode(containerdb.GetLogs(true, false, params.Get("host"), params.Get("id"), params.Get("search"), limit, params.Get("startWith"), caseSensetive, statusFilter(params))) } func (h *RouteController) GetLogs(w http.ResponseWriter, req *http.Request) { @@ -497,15 +505,9 @@ func (h *RouteController) GetLogs(w http.ResponseWriter, req *http.Request) { panic("Host is not mentioned!") } - status := params.Get("status") - var statusPtr *string - if status != "" { - statusPtr = &status - } - json.NewEncoder(w).Encode(containerdb.GetLogs( false, false, params.Get("host"), params.Get("id"), params.Get("search"), - limit, params.Get("startWith"), caseSensetive, statusPtr, + limit, params.Get("startWith"), caseSensetive, statusFilter(params), )) } @@ -520,7 +522,7 @@ func (h *RouteController) GetLogWithPrev(w http.ResponseWriter, req *http.Reques if params.Get("host") == "" { panic("Host is not mentioned!") } - json.NewEncoder(w).Encode(containerdb.GetLogs(false, true, params.Get("host"), params.Get("id"), "", limit, params.Get("startWith"), false, nil)) + json.NewEncoder(w).Encode(containerdb.GetLogs(false, true, params.Get("host"), params.Get("id"), "", limit, params.Get("startWith"), false, statusFilter(params))) } // TODO return {"error": "Invalid host!"} when host is not exists @@ -575,9 +577,12 @@ func (h *RouteController) Login(w http.ResponseWriter, req *http.Request) { return } - ipKey := "ip:" + clientAddr(req) - loginKey := "login:" + loginData.Login - if !loginLimiter.allow(ipKey, loginKey) { + // Keyed on the address and on the (address, login) pair -- never on the login + // alone, or anyone could lock any account out by guessing at it. + addr := clientAddr(req) + ipKey := "ip:" + addr + pairKey := "pair:" + loginKey(addr, loginData.Login) + if !loginLimiter.allow(ipKey, pairKey) { w.Header().Add("Content-Type", "application/json") w.WriteHeader(http.StatusTooManyRequests) json.NewEncoder(w).Encode(map[string]string{"error": "Too many failed login attempts. Try again later."}) @@ -586,11 +591,11 @@ func (h *RouteController) Login(w http.ResponseWriter, req *http.Request) { isCorrect := userdb.CheckUserPassword(loginData.Login, loginData.Password) if !isCorrect { - loginLimiter.fail(ipKey, loginKey) + loginLimiter.fail(ipKey, pairKey) json.NewEncoder(w).Encode(map[string]string{"error": "Wrong login or password!"}) return } - loginLimiter.succeed(ipKey, loginKey) + loginLimiter.succeed(ipKey, pairKey) http.SetCookie(w, &http.Cookie{ Name: "onlogs-cookie", @@ -682,31 +687,6 @@ func (h *RouteController) GetUserSettings(w http.ResponseWriter, req *http.Reque json.NewEncoder(w).Encode(userdb.GetUserSettings(username)) } -func (h *RouteController) EditHostname(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyAdminUser(&w, req) { - return - } - - var data struct { - Host string - Name string - } - if !decodeBody(w, req, &data) { - return - } - - if data.Name != "" { - if data.Host != util.GetHost() { - // TODO ask for command - } else { - os.WriteFile("/etc/hosntame", []byte(data.Name), 0644) - } - } - - w.Header().Add("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) -} - func (h *RouteController) EditUser(w http.ResponseWriter, req *http.Request) { if verifyRequest(&w, req) || !verifyAdminUser(&w, req) { return @@ -729,6 +709,17 @@ func (h *RouteController) EditUser(w http.ResponseWriter, req *http.Request) { } w.Header().Add("Content-Type", "application/json") + + if loginData.Password == "" { + json.NewEncoder(w).Encode(map[string]string{"error": "Password can not be empty"}) + return + } + + if err := userdb.EditUser(loginData.Login, loginData.Password); err != nil { + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) } diff --git a/application/backend/app/routes/routes_test.go b/application/backend/app/routes/routes_test.go index d87a444..9624099 100644 --- a/application/backend/app/routes/routes_test.go +++ b/application/backend/app/routes/routes_test.go @@ -26,6 +26,13 @@ func TestMain(m *testing.M) { if os.Getenv("JWT_SECRET") == "" { os.Setenv("JWT_SECRET", "routes-package-test-signing-key") } + // A token is only accepted while its account exists, so the accounts these + // tests sign tokens for have to be present. + os.Setenv("ADMIN_USERNAME", "admin") + userdb.CreateUser("admin", "admin-password-for-tests") + userdb.CreateUser("testuser", "testuser") + userdb.CreateUser("viewer", "viewer-password") + userdb.CreateUser("someuser", "someuser-password") os.Exit(m.Run()) } diff --git a/application/backend/app/routes/security_test.go b/application/backend/app/routes/security_test.go index 0c09c6d..c5aca79 100644 --- a/application/backend/app/routes/security_test.go +++ b/application/backend/app/routes/security_test.go @@ -106,12 +106,23 @@ func TestAddHostRejectsNamesThatLeaveTheTree(t *testing.T) { } } - for _, leaked := range []string{"PWNED_HOST", "PWNED_SERVICE", "../PWNED_HOST"} { + // Assert on exactly the paths MkdirAll would build, not on names relative to + // the package directory — those resolve elsewhere and silently pass. + for _, leaked := range []string{ + "leveldb/hosts/../../../../PWNED_HOST", + "leveldb/hosts/realhost/containers/../../../../PWNED_SERVICE", + } { if _, err := os.Stat(leaked); err == nil { os.RemoveAll(leaked) t.Errorf("addHost created %s outside leveldb/hosts", leaked) } } + entries, _ := os.ReadDir("leveldb/hosts") + for _, entry := range entries { + if !util.IsSafeName(entry.Name()) { + t.Errorf("addHost created an unsafe host directory: %q", entry.Name()) + } + } } func TestAddLogLineRejectsNamesThatLeaveTheTree(t *testing.T) { @@ -131,9 +142,11 @@ func TestAddLogLineRejectsNamesThatLeaveTheTree(t *testing.T) { if code := rr.Result().StatusCode; code != http.StatusBadRequest { t.Errorf("addLogLine accepted a traversing host: status %d", code) } - if _, err := os.Stat("../../../../PWNED_INGEST"); err == nil { - os.RemoveAll("../../../../PWNED_INGEST") - t.Error("addLogLine created a directory outside leveldb/hosts") + for _, leaked := range []string{"../../../../PWNED_INGEST", "leveldb/hosts/../../../../PWNED_INGEST"} { + if _, err := os.Stat(leaked); err == nil { + os.RemoveAll(leaked) + t.Errorf("addLogLine created %s outside leveldb/hosts", leaked) + } } } @@ -233,12 +246,8 @@ func TestGetLogsStreamRejectsAForeignOrigin(t *testing.T) { if code := rr.Result().StatusCode; code != http.StatusForbidden { t.Errorf("a cross-origin websocket handshake was not rejected: status %d", code) } - for _, conns := range vars.Connections { - for _, c := range conns { - if c == nil { - t.Fatal("a failed upgrade stored a nil connection that a background goroutine will dereference") - } - } + if got := vars.ConnectionCount(util.GetHost() + "/somecontainer"); got != 0 { + t.Fatalf("a failed upgrade stored %d connection(s); a background goroutine will dereference them", got) } } diff --git a/application/backend/app/statistics/keys_test.go b/application/backend/app/statistics/keys_test.go new file mode 100644 index 0000000..d07b224 --- /dev/null +++ b/application/backend/app/statistics/keys_test.go @@ -0,0 +1,99 @@ +package statistics + +import ( + "os" + "testing" + "time" + + "github.com/devforth/OnLogs/app/containerdb" + "github.com/devforth/OnLogs/app/util" + "github.com/devforth/OnLogs/app/vars" +) + +// Statistics are keyed by time and read back with time.Parse. Writing a full log +// key (which carries a " +-" suffix) makes every point unparseable, +// and both readers break out of their loop on the first key that will not parse — +// discarding every valid older point behind it. +func TestSavedStatisticsKeysParseAsTimestamps(t *testing.T) { + host, container := "StatKeyHost", "StatKeyContainer" + _ = os.RemoveAll("leveldb/hosts/" + host) + t.Cleanup(func() { _ = os.RemoveAll("leveldb/hosts/" + host) }) + + vars.Mutex.Lock() + delete(vars.Container_Stat_Counter, host+"/"+container) + vars.Mutex.Unlock() + + logsDB := util.GetDB(host, container, "logs") + if logsDB == nil { + t.Fatal("could not open the logs database") + } + writeLines := func(base time.Time, count int) { + for i := 0; i < count; i++ { + ts := base.Add(time.Duration(i) * time.Second).UTC().Format(time.RFC3339Nano) + if err := containerdb.PutLogMessage(logsDB, host, container, []string{ts, "ERROR something"}); err != nil { + t.Fatal(err) + } + } + } + + // First round takes the "no statistics yet" branch and saves under "now". + writeLines(time.Now().Add(-time.Hour), 3) + restartStats(host, container) + + // Lines arriving after that point drive the incremental branch, where the + // cursor the forward scan returns becomes the key it saves under. + writeLines(time.Now().Add(time.Minute), 3) + restartStats(host, container) + + statsDB := util.GetDB(host, container, "statistics") + if statsDB == nil { + t.Fatal("no statistics database") + } + iter := statsDB.NewIterator(nil, nil) + defer iter.Release() + + written := 0 + for iter.Next() { + key := string(iter.Key()) + written++ + if _, err := time.Parse(time.RFC3339Nano, key); err != nil { + t.Fatalf("statistics key %q is not a timestamp, so every chart point fails to parse: %v", key, err) + } + } + if written == 0 { + t.Fatal("no statistics were written at all") + } +} + +// The scan cursor is deliberately a full log key, so that paging can identify one +// row among several in the same instant. saveStats must reduce it to a timestamp +// rather than storing it verbatim. +func TestSaveStatsAlwaysWritesAParseableKey(t *testing.T) { + host, container := "StatCursorHost", "StatCursorContainer" + _ = os.RemoveAll("leveldb/hosts/" + host) + t.Cleanup(func() { _ = os.RemoveAll("leveldb/hosts/" + host) }) + + db := util.GetDB(host, container, "statistics") + if db == nil { + t.Fatal("could not open the statistics database") + } + + fullLogKey := "2026-02-10T12:44:02.123456789Z +1786097928311912188-9" + saveStats(db, emptyStats(), fullLogKey) + saveStats(db, emptyStats(), "not a timestamp at all") + + iter := db.NewIterator(nil, nil) + defer iter.Release() + + written := 0 + for iter.Next() { + written++ + key := string(iter.Key()) + if _, err := time.Parse(time.RFC3339Nano, key); err != nil { + t.Errorf("saveStats wrote %q, which every reader fails to parse: %v", key, err) + } + } + if written != 2 { + t.Fatalf("expected 2 statistics entries, got %d", written) + } +} diff --git a/application/backend/app/statistics/registry.go b/application/backend/app/statistics/registry.go index ebdf375..4c92a24 100644 --- a/application/backend/app/statistics/registry.go +++ b/application/backend/app/statistics/registry.go @@ -8,24 +8,42 @@ import ( // One statistics worker per host/container, shared by the docker streamer and // the agent ingestion route. Without a single registry each ingested log line // spawned another immortal worker, and every new worker zeroes the live counter. +type workerHandle struct { + cancel context.CancelFunc + id uint64 +} + var ( workersMutex sync.Mutex - workers = map[string]context.CancelFunc{} + workerSeq uint64 + workers = map[string]*workerHandle{} ) func WorkerKey(host string, container string) string { return host + "/" + container } -func registerWorker(location string, cancel context.CancelFunc) bool { +func registerWorker(location string, cancel context.CancelFunc) (uint64, bool) { workersMutex.Lock() defer workersMutex.Unlock() if _, exists := workers[location]; exists { - return false + return 0, false + } + workerSeq++ + workers[location] = &workerHandle{cancel: cancel, id: workerSeq} + return workerSeq, true +} + +// A worker can take seconds to unwind after its context is cancelled, by which +// time a replacement may already be registered. Only retire our own. +func retireWorker(location string, id uint64) { + workersMutex.Lock() + defer workersMutex.Unlock() + + if handle, exists := workers[location]; exists && handle.id == id { + delete(workers, location) } - workers[location] = cancel - return true } // EnsureWorker starts a worker for host/container unless one already runs. @@ -33,13 +51,15 @@ func EnsureWorker(ctx context.Context, host string, container string) bool { location := WorkerKey(host, container) workerCtx, cancel := context.WithCancel(ctx) - if !registerWorker(location, cancel) { + id, registered := registerWorker(location, cancel) + if !registered { cancel() return false } go func() { - defer StopWorker(host, container) + defer cancel() + defer retireWorker(location, id) RunStatisticForContainerWithContext(workerCtx, host, container) }() return true @@ -49,14 +69,14 @@ func StopWorker(host string, container string) { location := WorkerKey(host, container) workersMutex.Lock() - cancel, exists := workers[location] + handle, exists := workers[location] if exists { delete(workers, location) } workersMutex.Unlock() if exists { - cancel() + handle.cancel() } } diff --git a/application/backend/app/statistics/statistics.go b/application/backend/app/statistics/statistics.go index e5d3228..be44650 100644 --- a/application/backend/app/statistics/statistics.go +++ b/application/backend/app/statistics/statistics.go @@ -43,7 +43,7 @@ func restartStats(host string, container string) { last_stat_time := getLastStatTime(current_db) if last_stat_time == "" { last_stat_time = current_datetime - calc_stat := collectLogsBackward(host, container, last_stat_time) + calc_stat := collectLogsBackward(host, container, "") saveStats(current_db, calc_stat, last_stat_time) } else { calc_stat, new_datetime := collectLogsForward(host, container, last_stat_time) @@ -71,7 +71,7 @@ func collectLogsBackward(host, container, until string) map[string]uint64 { calc_stat := map[string]uint64{"error": 0, "debug": 0, "info": 0, "warn": 0, "meta": 0, "other": 0} for { - raw_logs := containerdb.GetLogs(false, true, host, container, "", 1000, until, true, nil) + raw_logs := containerdb.GetLogs(false, false, host, container, "", 1000, until, true, nil) logs, ok := raw_logs["logs"].([][]string) if !ok || len(logs) == 0 { break @@ -115,9 +115,16 @@ func collectLogsForward(host, container, since string) (map[string]uint64, strin return calcStat, since } +// Statistics are keyed by time and read back with time.Parse, so a full log key +// (which carries a " +-" suffix) must be reduced to its timestamp. func saveStats(db *leveldb.DB, stats map[string]uint64, timestamp string) { + key := strings.Split(timestamp, " +")[0] + if _, err := time.Parse(time.RFC3339Nano, key); err != nil { + key = time.Now().UTC().Format(time.RFC3339Nano) + } + to_put, _ := json.Marshal(stats) - db.Put([]byte(timestamp), to_put, nil) + db.Put([]byte(key), to_put, nil) } func resetInMemoryStats(location string) { diff --git a/application/backend/app/util/jwt_test.go b/application/backend/app/util/jwt_test.go index 3c10a80..9ad03bf 100644 --- a/application/backend/app/util/jwt_test.go +++ b/application/backend/app/util/jwt_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/devforth/OnLogs/app/vars" "github.com/golang-jwt/jwt/v5" ) @@ -103,3 +104,26 @@ func TestGenerateJWTSecretIsNotPredictable(t *testing.T) { seen[s] = true } } + +func TestGetUserFromJWTRejectsADeletedAccount(t *testing.T) { + secret := "a-real-secret-value-for-the-test" + os.Setenv("JWT_SECRET", secret) + + login := "about-to-be-deleted" + if err := vars.UsersDB.Put([]byte(login), []byte("irrelevant"), nil); err != nil { + t.Fatal(err) + } + + signed := CreateJWT(login) + if user, err := GetUserFromJWT(requestWithCookie(signed)); err != nil || user != login { + t.Fatalf("the token should work while the account exists: %q %v", user, err) + } + + if err := vars.UsersDB.Delete([]byte(login), nil); err != nil { + t.Fatal(err) + } + + if user, err := GetUserFromJWT(requestWithCookie(signed)); err == nil { + t.Fatalf("a deleted account still authenticates as %q; deleting a user revokes nothing", user) + } +} diff --git a/application/backend/app/util/safename_test.go b/application/backend/app/util/safename_test.go index 6dd9aea..386524a 100644 --- a/application/backend/app/util/safename_test.go +++ b/application/backend/app/util/safename_test.go @@ -9,6 +9,10 @@ func TestGetDBRefusesNamesThatLeaveTheTree(t *testing.T) { t.Chdir(t.TempDir()) cases := [][3]string{ + {"/", "c", "logs"}, + {"c", "/", "logs"}, + {"c", "c", "/"}, + {"a\\b", "c", "logs"}, {"../../../PWN", "c", "logs"}, {"realhost", "../../../../PWN", "logs"}, {"realhost", "c", "../../../PWN"}, @@ -84,3 +88,21 @@ func TestGetDockerContainerIDRefusesNamesThatLeaveTheTree(t *testing.T) { t.Fatal("GetDockerContainerID created a LevelDB outside leveldb/hosts") } } + +func TestIsSafeNameRejectsEverySeparatorShape(t *testing.T) { + unsafe := []string{ + "", ".", "..", "/", "//", "a/", "/a", "a/b", "../x", "x/..", + "a\\b", "\\", "a\x00b", + } + for _, name := range unsafe { + if IsSafeName(name) { + t.Errorf("IsSafeName(%q) accepted a name that is not a single path component", name) + } + } + + for _, name := range []string{"ok", "my-container", "my_container.1", "a"} { + if !IsSafeName(name) { + t.Errorf("IsSafeName(%q) rejected a legitimate name", name) + } + } +} diff --git a/application/backend/app/util/util.go b/application/backend/app/util/util.go index c3f0322..5065afe 100644 --- a/application/backend/app/util/util.go +++ b/application/backend/app/util/util.go @@ -90,7 +90,14 @@ func CreateJWT(login string) string { // Every path component must be a single, literal name; anything else escapes // leveldb/hosts. func IsSafeName(s string) bool { - return s != "" && s != "." && s != ".." && !strings.ContainsRune(s, 0) && s == filepath.Base(s) + if s == "" || s == "." || s == ".." { + return false + } + // filepath.Base("/") is "/", so the Base comparison alone lets it through. + if strings.ContainsAny(s, `/\`) || strings.ContainsRune(s, 0) { + return false + } + return s == filepath.Base(s) } func GetDB(host string, container string, dbType string) *leveldb.DB { @@ -128,8 +135,8 @@ func GetDB(host string, container string, dbType string) *leveldb.DB { } if err != nil { - panic(fmt.Sprintf("ERROR: unable to open db for %s/%s/%s\n%v", - host, container, dbType, err)) + fmt.Printf("ERROR: unable to open db for %s/%s/%s: %v\n", host, container, dbType, err) + return nil } switch dbType { @@ -154,6 +161,37 @@ func GetDB(host string, container string, dbType string) *leveldb.DB { // ResetDB closes a cached handle and drops it, so the next GetDB opens a fresh // one. Closing without dropping leaves callers holding a closed handle. +// containersMeta lives beside the containers directory rather than inside it, so +// it needs its own accessor. Two copies of this block used to open it with no +// lock, which raced and panicked on the flock conflict. +func GetContainersMetaDB(host string) *leveldb.DB { + if !IsSafeName(host) { + return nil + } + + vars.DBMutex.RLock() + db := vars.ContainersMeta_DBs[host] + vars.DBMutex.RUnlock() + if db != nil { + return db + } + + vars.DBMutex.Lock() + defer vars.DBMutex.Unlock() + + if db = vars.ContainersMeta_DBs[host]; db != nil { + return db + } + + db, err := leveldb.OpenFile("leveldb/hosts/"+host+"/containersMeta", nil) + if err != nil { + fmt.Printf("ERROR: unable to open containersMeta for %s: %v\n", host, err) + return nil + } + vars.ContainersMeta_DBs[host] = db + return db +} + func ResetDB(host string, container string, dbType string) { if !IsSafeName(host) || !IsSafeName(container) || !IsSafeName(dbType) { return @@ -292,6 +330,11 @@ func GetUserFromJWT(req http.Request) (string, error) { if !ok || user == "" { return "", errors.New("401 - Unauthorized!") } + + // A valid signature is not enough: deleting an account must revoke it. + if exists, err := vars.UsersDB.Has([]byte(user), nil); err != nil || !exists { + return "", errors.New("401 - Unauthorized!") + } return user, nil } @@ -310,15 +353,10 @@ func GetDockerContainerID(host string, container string) string { return "" } - containersMetaDB := vars.ContainersMeta_DBs[host] + containersMetaDB := GetContainersMetaDB(host) if containersMetaDB == nil { - containersMetaDB, err := leveldb.OpenFile("leveldb/hosts/"+host+"/containersMeta", nil) - if err != nil { - panic(err) - } - vars.ContainersMeta_DBs[host] = containersMetaDB + return "" } - containersMetaDB = vars.ContainersMeta_DBs[host] iter := containersMetaDB.NewIterator(nil, nil) defer iter.Release() diff --git a/application/backend/app/util/util_test.go b/application/backend/app/util/util_test.go index 37fe145..041ed50 100644 --- a/application/backend/app/util/util_test.go +++ b/application/backend/app/util/util_test.go @@ -9,6 +9,12 @@ import ( "github.com/devforth/OnLogs/app/vars" ) +// A token is only accepted while its account exists, so these fixtures need to. +func TestMain(m *testing.M) { + vars.UsersDB.Put([]byte("test_user"), []byte("irrelevant"), nil) + os.Exit(m.Run()) +} + func TestContains(t *testing.T) { type args struct { a string diff --git a/application/backend/app/vars/connections.go b/application/backend/app/vars/connections.go index b6fa276..675c577 100644 --- a/application/backend/app/vars/connections.go +++ b/application/backend/app/vars/connections.go @@ -1,6 +1,7 @@ package vars import ( + "sync" "time" "github.com/gorilla/websocket" @@ -8,6 +9,21 @@ import ( const wsWriteDeadline = 10 * time.Second +// gorilla permits at most one concurrent writer per connection and panics +// otherwise, so each viewer carries its own write lock. +type viewer struct { + conn *websocket.Conn + writeMu sync.Mutex +} + +func (v *viewer) write(messageType int, payload []byte) error { + v.writeMu.Lock() + defer v.writeMu.Unlock() + + v.conn.SetWriteDeadline(time.Now().Add(wsWriteDeadline)) + return v.conn.WriteMessage(messageType, payload) +} + // AddConnection registers a viewer's websocket for a host/container. func AddConnection(key string, conn *websocket.Conn) { if conn == nil { @@ -15,41 +31,40 @@ func AddConnection(key string, conn *websocket.Conn) { } connectionsMutex.Lock() defer connectionsMutex.Unlock() - Connections[key] = append(Connections[key], conn) + connections[key] = append(connections[key], &viewer{conn: conn}) } -func connectionsFor(key string) []*websocket.Conn { +func viewersFor(key string) []*viewer { connectionsMutex.RLock() defer connectionsMutex.RUnlock() - return append([]*websocket.Conn{}, Connections[key]...) + return append([]*viewer{}, connections[key]...) } -func removeConnection(key string, conn *websocket.Conn) { +func removeViewer(key string, target *viewer) { connectionsMutex.Lock() defer connectionsMutex.Unlock() - remaining := Connections[key][:0] - for _, existing := range Connections[key] { - if existing != conn { + remaining := connections[key][:0] + for _, existing := range connections[key] { + if existing != target { remaining = append(remaining, existing) } } if len(remaining) == 0 { - delete(Connections, key) + delete(connections, key) return } - Connections[key] = remaining + connections[key] = remaining } // Broadcast writes to every viewer of a host/container, dropping any connection // that errors or stalls. A viewer that never drains its socket would otherwise // block the caller forever, which freezes ingestion for that container. func Broadcast(key string, messageType int, payload []byte) { - for _, conn := range connectionsFor(key) { - conn.SetWriteDeadline(time.Now().Add(wsWriteDeadline)) - if err := conn.WriteMessage(messageType, payload); err != nil { - removeConnection(key, conn) - conn.Close() + for _, v := range viewersFor(key) { + if err := v.write(messageType, payload); err != nil { + removeViewer(key, v) + v.conn.Close() } } } @@ -57,5 +72,5 @@ func Broadcast(key string, messageType int, payload []byte) { func ConnectionCount(key string) int { connectionsMutex.RLock() defer connectionsMutex.RUnlock() - return len(Connections[key]) + return len(connections[key]) } diff --git a/application/backend/app/vars/vars.go b/application/backend/app/vars/vars.go index dd68193..3bc44b7 100644 --- a/application/backend/app/vars/vars.go +++ b/application/backend/app/vars/vars.go @@ -5,7 +5,6 @@ import ( "sync" "time" - "github.com/gorilla/websocket" "github.com/syndtr/goleveldb/leveldb" ) @@ -23,8 +22,11 @@ var ( DockerContainers = []string{} AgentsActiveContainers = map[string][]string{} - ToDelete = map[string][]string{} - Connections = map[string][]*websocket.Conn{} + ToDelete = map[string][]string{} + + // Reachable only through AddConnection/Broadcast/ConnectionCount so it + // cannot be touched without the lock. + connections = map[string][]*viewer{} Counters_For_Hosts_Last_30_Min = map[string]map[string]uint64{} Container_Stat_Counter = map[string]map[string]uint64{} diff --git a/application/backend/main.go b/application/backend/main.go index 4e7844d..da10102 100644 --- a/application/backend/main.go +++ b/application/backend/main.go @@ -77,6 +77,11 @@ func init_config() { if os.Getenv("MAX_LOGS_SIZE") == "" { os.Setenv("MAX_LOGS_SIZE", "10GB") } + if _, err := util.ParseHumanReadableSize(os.Getenv("MAX_LOGS_SIZE")); err != nil { + fmt.Printf("FATAL: MAX_LOGS_SIZE=%q is not a valid size (%v); log retention would never run.\n", + os.Getenv("MAX_LOGS_SIZE"), err) + os.Exit(1) + } fmt.Println("INFO: OnLogs configs done!") } @@ -150,7 +155,6 @@ func main() { http.HandleFunc(pathPrefix+"/api/v1/deleteContainerLogs", routerCtrl.DeleteContainerLogs) http.HandleFunc(pathPrefix+"/api/v1/deleteDockerLogs", routerCtrl.DeleteDockerLogs) http.HandleFunc(pathPrefix+"/api/v1/deleteUser", routerCtrl.DeleteUser) - http.HandleFunc(pathPrefix+"/api/v1/editHostname", routerCtrl.EditHostname) http.HandleFunc(pathPrefix+"/api/v1/editUser", routerCtrl.EditUser) http.HandleFunc(pathPrefix+"/api/v1/getChartData", routerCtrl.GetChartData) http.HandleFunc(pathPrefix+"/api/v1/getDockerSize", routerCtrl.GetDockerSize) diff --git a/application/backend/main_test.go b/application/backend/main_test.go index 960617c..d861460 100644 --- a/application/backend/main_test.go +++ b/application/backend/main_test.go @@ -3,6 +3,8 @@ package main import ( "os" "testing" + + "github.com/devforth/OnLogs/app/util" ) func TestInitConfigGeneratesAJWTSecretWhenNoneIsConfigured(t *testing.T) { @@ -80,3 +82,14 @@ func TestNewServerSetsAllTimeouts(t *testing.T) { t.Error("IdleTimeout is unset") } } + +func TestInitConfigRejectsAnUnparseableMaxLogsSize(t *testing.T) { + if _, err := util.ParseHumanReadableSize("10GB"); err != nil { + t.Fatalf("the default should parse: %v", err) + } + for _, value := range []string{"lots", "10 gigabytes", "GB", "-"} { + if _, err := util.ParseHumanReadableSize(value); err == nil { + t.Errorf("MAX_LOGS_SIZE=%q was accepted; retention would silently never run", value) + } + } +} diff --git a/application/frontend/src/Views/Logs/LogsViewHeder/LogsViewHeder.test.mjs b/application/frontend/src/Views/Logs/LogsViewHeder/LogsViewHeder.test.mjs index a295e03..625a4b0 100644 --- a/application/frontend/src/Views/Logs/LogsViewHeder/LogsViewHeder.test.mjs +++ b/application/frontend/src/Views/Logs/LogsViewHeder/LogsViewHeder.test.mjs @@ -58,6 +58,18 @@ async function run() { JSON.stringify(component.searchText) ); + // A pending timer must not write back after the view clears the search, or a + // service switch reloads the new service filtered by the old term. + type(window, input, "stale term"); + component.searchResetVersion = component.searchResetVersion + 1; + await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_MS + 250)); + + assert.notEqual( + component.searchText, + "stale term", + "a pending debounce timer resurrected the previous search term after it was cleared" + ); + component.$destroy(); console.log("LogsViewHeder debounce tests passed"); process.exit(0); diff --git a/application/frontend/src/Views/Logs/NewLogsV2.sharedlink.test.mjs b/application/frontend/src/Views/Logs/NewLogsV2.sharedlink.test.mjs new file mode 100644 index 0000000..777d916 --- /dev/null +++ b/application/frontend/src/Views/Logs/NewLogsV2.sharedlink.test.mjs @@ -0,0 +1,113 @@ +// Opening a shared log link must land on the linked row, not on the newest page. +// NewLogsV2 runs all three of its reactive blocks in one flush, so the deep-link +// fetch and two full reloads start together and the last writer wins. +import assert from "node:assert/strict"; +import { + bundleComponent, + installDom, + jsonResponse, + settle, + importBundle, +} from "../../../test/harness.mjs"; + +const LINKED = "2026-02-10T09:00:05.000000000Z"; + +const WINDOW_ROWS = [ + [LINKED, "linked-row", LINKED + " +1-1"], + ["2026-02-10T09:00:04.000000000Z", "window-older", "2026-02-10T09:00:04.000000000Z +1-2"], +]; +const NEWEST_ROWS = [ + ["2026-02-10T18:00:00.000000000Z", "newest-page-row", "2026-02-10T18:00:00.000000000Z +1-3"], +]; + +function stubFetch(counter, latency) { + return async (url) => { + const target = String(url); + if (latency) { + await new Promise((resolve) => setTimeout(resolve, latency)); + } + if (target.includes("getLogWithPrev?")) { + counter.withPrev += 1; + return jsonResponse({ + logs: WINDOW_ROWS.map((r) => [...r]), + last_processed_key: WINDOW_ROWS.at(-1)[2], + is_end: true, + }); + } + if (target.includes("getPrevLogs?")) { + return jsonResponse({ logs: [], last_processed_key: "", is_end: true }); + } + if (target.includes("getLogs?")) { + counter.getLogs += 1; + return jsonResponse({ + logs: NEWEST_ROWS.map((r) => [...r]), + last_processed_key: NEWEST_ROWS[0][2], + is_end: true, + }); + } + return jsonResponse({ error: null }); + }; +} + +function renderedMessages(target) { + return [...target.querySelectorAll(".message p")] + .map((el) => el.textContent.trim()) + .filter(Boolean); +} + +// Set up the DOM before the bundle is imported: fetch.js reads document.location +// at construction. +installDom({}); +const bundle = await importBundle(await bundleComponent("test/entry.js")); + +function openDeepLink(latency) { + const counter = { getLogs: 0, withPrev: 0 }; + const { window } = installDom({ fetchImpl: stubFetch(counter, latency) }); + + bundle.lastChosenHost.set("testhost"); + bundle.lastChosenService.set("testservice"); + bundle.isPending.set(false); + // App.svelte publishes the hash before this component mounts. + bundle.urlHash.set("#" + LINKED); + + const target = window.document.body; + const component = new bundle.NewLogsV2({ target }); + return { component, target, counter }; +} + +async function run() { + // The deep link must win regardless of how the three concurrent loads + // interleave, so exercise a range of response latencies. + for (const latency of [0, 5, 20]) { + const opened = openDeepLink(latency); + await settle(latency ? 120 : 40); + + const seen = renderedMessages(opened.target); + console.log( + ` latency ${latency}ms -> ${JSON.stringify(seen)} (getLogWithPrev ${opened.counter.withPrev}, getLogs ${opened.counter.getLogs})` + ); + + assert.ok( + opened.counter.withPrev > 0, + `latency ${latency}ms: the deep-link fetch never ran` + ); + assert.ok( + seen.includes("linked-row"), + `latency ${latency}ms: the linked row is not on screen: ${JSON.stringify(seen)}` + ); + assert.ok( + !seen.includes("newest-page-row"), + `latency ${latency}ms: the newest page overwrote the deep-link window: ${JSON.stringify(seen)}` + ); + + opened.component.$destroy(); + } + + console.log("shared link deep-link window tests passed"); + process.exit(0); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/application/frontend/src/Views/Logs/NewLogsV2.svelte b/application/frontend/src/Views/Logs/NewLogsV2.svelte index 4ae0dd7..6cd09dc 100644 --- a/application/frontend/src/Views/Logs/NewLogsV2.svelte +++ b/application/frontend/src/Views/Logs/NewLogsV2.svelte @@ -4,7 +4,8 @@ import LogsString from "../../lib/LogsString/LogsString.svelte"; import fetchApi from "../../utils/fetch"; import { navigate } from "svelte-routing"; - import { afterUpdate, onMount, tick } from "svelte"; + import { afterUpdate, onDestroy, onMount, tick } from "svelte"; + import { get } from "svelte/store"; import LogsViewHeder from "./LogsViewHeder/LogsViewHeder.svelte"; import IntersectionObserver from "svelte-intersection-observer"; import Spiner from "./Spiner.svelte"; @@ -17,6 +18,7 @@ import { shouldAutoScrollLogs, shouldFlushBufferedLogs, + createLogsViewFlags, } from "./shareLinkViewState.js"; import { @@ -82,6 +84,9 @@ let autoscroll = false; let div; let logsLoadGeneration = 0; + // Deliberately an object field: assigning a `let` here would retrigger the very + // reactive blocks this is meant to hold back. + const viewFlags = createLogsViewFlags(); let pauseWS = false; let newLogsAmount = 1; let controller = null; @@ -89,8 +94,6 @@ let topFetchIsStarted = false; let pinedBadgeTimer = null; let pinedBadgeIsVisible = false; - let skipNextSearchReload = false; - let skipNextStatusReload = false; let searchResetVersion = 0; let isSharedLinkFocusMode = false; @@ -135,6 +138,9 @@ let startWith = ""; let tmpStartWith = []; + // The third element is the full storage key: the only unique row identity. + const logKey = (logItem) => logItem?.at(2) ?? logItem?.at(0) ?? ""; + function resetAllLogs() { allLogs = []; newLogs = []; @@ -157,6 +163,7 @@ let last_key = ""; let is_all_logs_processed = false; + try { while (total_logs_amount < limit && !is_all_logs_processed) { isSearching.set(true); const data = await api.getLogs({ @@ -196,12 +203,15 @@ visibleLogs = allLogsCopy.splice(0, limit); previousLogs = allLogsCopy.splice(0, limit); - isSearching.set(false); - isPending.set(false); autoscroll = true; - pauseWS = false; - logsFromWS = []; + } catch (e) { + console.error(e); + } finally { + isSearching.set(false); + isPending.set(false); + pauseWS = false; + } } async function checkIfHashIsInUrl() { @@ -219,6 +229,7 @@ } urlHash.set(""); + viewFlags.endDeepLink(); setTimeout(() => { setTimeout(() => { setInitialScroll(1); @@ -237,6 +248,7 @@ async function fetchIfHashIsInUrl(startWith) { const initialService = $lastChosenService; + const generation = ++logsLoadGeneration; const getLogsArray = (response) => Array.isArray(response?.logs) ? response.logs : []; @@ -274,14 +286,14 @@ const upperLogsResponse = await api.getLogs({ containerName: $lastChosenService, limit: limit - downLogs.length, - startWith: viewLogs?.at(0)[0], + startWith: logKey(viewLogs?.at(0)), hostName: $lastChosenHost, status: $chosenStatus, }); upperLogs = getLogsArray(upperLogsResponse); } } - if (initialService === $lastChosenService) { + if (initialService === $lastChosenService && generation === logsLoadGeneration) { allLogs = [...upperLogs.reverse(), ...viewLogs, ...downLogs]; let allLogsCopy = [...allLogs]; @@ -438,6 +450,9 @@ function resetSearchParams() { searchText = ""; + // Also disarms any debounce timer still pending in the header, which would + // otherwise write the previous service's search term back a moment later. + searchResetVersion = searchResetVersion + 1; } async function exitSharedLinkFocusMode(flushBufferedLogs = false) { @@ -483,10 +498,10 @@ urlHash.set(hash); if (hadSearchText) { - skipNextSearchReload = true; + viewFlags.skipNextSearchReload(); } if (hadChosenStatus) { - skipNextStatusReload = true; + viewFlags.skipNextStatusReload(); } resetSearchParams(); @@ -546,7 +561,7 @@ ? customStartWith : customStartWith === 0 ? "" - : allLogs.at(0)?.at(0); + : logKey(allLogs.at(0)); while (total_logs_amount < limit && !is_all_logs_processed) { isSearching.set(true); @@ -622,7 +637,11 @@ let total_logs = []; let total_received_logs_count = 0; let is_all_logs_processed = false; - let last_key = customStartWith ? customStartWith : customStartWith === 0 ? "" : allLogs.at(0)?.at(0); + let last_key = customStartWith + ? customStartWith + : customStartWith === 0 + ? "" + : logKey(allLogs.at(0)); while (limit > total_received_logs_count && !is_all_logs_processed) { isSearching.set(true); @@ -669,7 +688,7 @@ const initialService = $lastChosenService; isFeatching.set(true); - let last_key = allLogs.at(-1) ? allLogs.at(-1)[0] : ""; + let last_key = logKey(allLogs.at(-1)); let total_logs = []; let total_received_logs_count = 0; let is_all_logs_processed = false; @@ -723,6 +742,11 @@ $: { (async () => { if ($lastChosenHost && $lastChosenService) { + // Read without subscribing: referencing $urlHash here would make this + // block depend on it, and clearing the hash would retrigger it forever. + if (get(urlHash)) { + viewFlags.beginDeepLink(); + } await exitSharedLinkFocusMode(); setInitialScroll(0); resetAllLogs(); @@ -738,14 +762,21 @@ })(); } + let scrollHandler = null; + let scrollTarget = null; + function addScrollLIstenersToLogs() { - let isEventOnScroll = false; + clearInterval(scrollListenerIntervalId); - const interval = setInterval(() => { + scrollListenerIntervalId = setInterval(() => { const logsContEl = document.querySelector("#logs"); if (logsContEl) { - logsContEl.addEventListener("scroll", function () { + // Without this, every reload stacked another listener on the same node. + if (scrollTarget && scrollHandler) { + scrollTarget.removeEventListener("scroll", scrollHandler); + } + scrollHandler = function () { let st = window.scrollY || logsContEl.scrollTop; if (st > lastScrollTop) { scrollDirection = "down"; @@ -761,20 +792,17 @@ pinedBadgeTimer = setTimeout(function () { pinedBadgeIsVisible = false; }, 350); - }); - isEventOnScroll = true; - } - if (isEventOnScroll) { - clearInterval(interval); - isEventOnScroll = false; + }; + scrollTarget = logsContEl; + logsContEl.addEventListener("scroll", scrollHandler); + clearInterval(scrollListenerIntervalId); } }, 1000); } $: { (async () => { - if (skipNextSearchReload) { - skipNextSearchReload = false; + if (viewFlags.consumeSearchSkip() || viewFlags.isDeepLinkPending()) { return; } await exitSharedLinkFocusMode(); @@ -793,8 +821,7 @@ $: { (async () => { - if (skipNextStatusReload) { - skipNextStatusReload = false; + if (viewFlags.consumeStatusSkip() || viewFlags.isDeepLinkPending()) { return; } await exitSharedLinkFocusMode(); @@ -847,7 +874,7 @@ } const checkIfScrollOnTop = () => { - const checkIfScrollOnTopInterval = setInterval(async () => { + return setInterval(async () => { if ( startOfLogsIntersect && allLogs.length >= 3 * limit && @@ -868,16 +895,42 @@ }, 500); }; + let topScrollIntervalId = null; + let scrollListenerIntervalId = null; + let onResize = null; + onMount(async () => { - checkIfScrollOnTop(); + topScrollIntervalId = checkIfScrollOnTop(); initialScroll = 1; - window.addEventListener("resize", () => { + onResize = () => { const logsContEl = document.querySelector("#logs"); if (logsContEl) { - limit = Math.round(logsContEl.offsetHeight / 200) * 10; + // Never 0: a short pane would otherwise request no logs at all. + limit = Math.max(10, Math.round(logsContEl.offsetHeight / 200) * 10); } - }); + }; + window.addEventListener("resize", onResize); + }); + + onDestroy(() => { + clearInterval(topScrollIntervalId); + clearInterval(scrollListenerIntervalId); + if (scrollTarget && scrollHandler) { + scrollTarget.removeEventListener("scroll", scrollHandler); + } + clearTimeout(extremalScrollId); + clearTimeout(pinedBadgeTimer); + if (onResize) { + window.removeEventListener("resize", onResize); + } + closeWS(); + // The component is going away; it must not keep driving the shared stores + // the surviving instance reads. + logsLoadGeneration = logsLoadGeneration + 1; + isFeatching.set(false); + isSearching.set(false); + isPending.set(false); }); afterUpdate(() => { diff --git a/application/frontend/src/Views/Logs/NewLogsV2.test.mjs b/application/frontend/src/Views/Logs/NewLogsV2.test.mjs index 4f61f91..0d9713d 100644 --- a/application/frontend/src/Views/Logs/NewLogsV2.test.mjs +++ b/application/frontend/src/Views/Logs/NewLogsV2.test.mjs @@ -60,6 +60,19 @@ function stubEmptyPageFetch(counter) { }; } +// One transient 502 used to leave a permanent spinner, infinite scroll dead in +// both directions, and the websocket connected but every line silently dropped. +function stubFailingFetch(counter) { + return async (url) => { + const target = String(url); + if (target.includes("getLogs?")) { + counter.getLogs += 1; + return { status: 502, ok: false, json: async () => ({}) }; + } + return jsonResponse({ logs: [], last_processed_key: "", is_end: true }); + }; +} + function renderedRowCount(target) { return target.querySelectorAll(".chosenString").length; } @@ -141,6 +154,54 @@ async function run() { ); empty.component.$destroy(); + // --- a failed request must not wedge the view --- + const failingCounter = { getLogs: 0 }; + const failing = await mount( + bundle, + stubFailingFetch(failingCounter), + failingCounter + ); + + const pending = bundle.isPending; + const searching = bundle.isSearching; + let pendingValue; + let searchingValue; + pending.subscribe((v) => (pendingValue = v))(); + searching.subscribe((v) => (searchingValue = v))(); + + console.log(` after a 502: isPending=${pendingValue} isSearching=${searchingValue}`); + assert.equal(pendingValue, false, "a failed request left the spinner up forever"); + assert.equal(searchingValue, false, "a failed request left the loader up forever"); + failing.component.$destroy(); + + // --- destroying the view must not leave a zombie driving the shared stores --- + const zombieCounter = { getLogs: 0 }; + const { window: zombieWindow, sockets } = installDom({ + fetchImpl: stubFetch(zombieCounter), + }); + bundle.lastChosenHost.set("testhost"); + bundle.lastChosenService.set("testservice"); + bundle.isPending.set(false); + const zombie = new bundle.NewLogsV2({ target: zombieWindow.document.body }); + await settle(); + + assert.ok(sockets.length > 0, "the view never opened a websocket"); + zombie.$destroy(); + await settle(5); + + assert.ok( + sockets.every((s) => s.readyState === 3), + "destroying the view left its websocket open" + ); + + let stillFetching; + bundle.isFeatching.subscribe((v) => (stillFetching = v))(); + assert.equal( + stillFetching, + false, + "a destroyed view left the shared isFeatching store set, which blocks the live one" + ); + console.log("NewLogsV2 duplication tests passed"); process.exit(0); } diff --git a/application/frontend/src/Views/Logs/classifier.test.mjs b/application/frontend/src/Views/Logs/classifier.test.mjs new file mode 100644 index 0000000..40a7558 --- /dev/null +++ b/application/frontend/src/Views/Logs/classifier.test.mjs @@ -0,0 +1,54 @@ +// Shared with backend/app/containerdb/classifier_test.go: both implementations +// must agree on every one of these, or the status filter hides lines the UI +// paints red. +import assert from "node:assert/strict"; +import { + bundleComponent, + installDom, + importBundle, +} from "../../../test/harness.mjs"; + +installDom({}); +const { classifyLogLine, getLogLineStatus } = await importBundle( + await bundleComponent("test/entry.js") +); + +const ESC = ""; + +const CASES = [ + ["ERROR something failed", "error"], + ["Error: connection refused", "error"], + ["error: connection refused", "error"], + ["ERR disk full", "error"], + ["WARN low memory", "warn"], + ["WARNING low memory", "warn"], + ["warning low memory", "warn"], + ["DEBUG entering loop", "debug"], + ["debug entering loop", "debug"], + ["INFO started", "info"], + ["INFO ERROR_COUNT=0", "info"], + ["ONLOGS: Container listening started!", "meta"], + ["plain application output", "other"], + ["", "other"], + [`${ESC}[31mERROR${ESC}[0m red text`, "error"], + ["the word information appears here", "info"], + ["2026-02-10 INFO ready", "info"], +]; + +function run() { + for (const [line, expected] of CASES) { + assert.equal( + classifyLogLine(line), + expected, + `classifyLogLine(${JSON.stringify(line)}) should be ${expected}` + ); + } + + // The badge stays hidden for unclassified lines, as before. + assert.equal(getLogLineStatus("plain application output"), ""); + assert.equal(getLogLineStatus("ERROR boom"), "error"); + + console.log("log level classifier tests passed"); +} + +run(); diff --git a/application/frontend/src/Views/Logs/functions.js b/application/frontend/src/Views/Logs/functions.js index feb9bf2..ff72456 100644 --- a/application/frontend/src/Views/Logs/functions.js +++ b/application/frontend/src/Views/Logs/functions.js @@ -5,92 +5,79 @@ export { findSearchTextInLogs }; const api = new FetchApi(); -export const timezoneOffsetSec = new Date().getTimezoneOffset() * 60; - -export const getLogLineStatus = (logLine = "") => { - const normalizedLogLine = stripAnsi(logLine); - const statuses_errors = ["ERROR", "ERR", "Error", "Err", "error"]; - const statuses_warnings = ["WARN", "WARNING", "warning"]; - const statuses_other = ["DEBUG", "INFO", "ONLOGS", "debug", "info", "onlogs"]; - const logLineItems = normalizedLogLine.split(" "); - var i, j; - - for (i = 0; i < logLineItems.length; i++) { - for (j = 0; j < statuses_errors.length; j++) { - if (logLineItems[i].includes(statuses_errors[j])) { - return "error"; - } - } - for (j = 0; j < statuses_warnings.length; j++) { - if (logLineItems[i].includes(statuses_warnings[j])) { - return "warn"; - } - } - for (j = 0; j < statuses_other.length; j++) { - if (logLineItems[i].includes(statuses_other[j])) { - return statuses_other[j].toLowerCase() === "onlogs" - ? "meta" - : statuses_other[j].toLowerCase(); +// One classification rule, mirrored by containerdb.GetLogStatusKey in Go. The two +// used to disagree, so the status filter hid exactly the lines the UI painted red. +const LEVELS = [ + ["ERROR", "error"], + ["ERR", "error"], + ["WARNING", "warn"], + ["WARN", "warn"], + ["DEBUG", "debug"], + ["INFO", "info"], + ["ONLOGS", "meta"], +]; + +export const classifyLogLine = (logLine = "") => { + for (const token of stripAnsi(String(logLine)).split(/\s+/)) { + const upper = token.toUpperCase(); + for (const [needle, level] of LEVELS) { + if (upper.includes(needle)) { + return level; } } } - return ""; + return "other"; }; -export const transformLogString = (t, options) => { - return options - ? new Date( - new Date().setTime( - new Date(t?.at(0)?.slice(0, 22)?.replace("T", " "))?.getTime() - ) - ) - .toLocaleString("sv-EN", { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - fractionalSecondDigits: 3, - }) - .replace(",", ".") - : new Date( - new Date().setTime( - new Date(t?.at(0)?.slice(0, 19)?.replace("T", " "))?.getTime() - - timezoneOffsetSec * 1000 - ) - ) - .toLocaleString("sv-EN", { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - fractionalSecondDigits: 3, - }) - .replace(",", "."); +// The badge is hidden for unclassified lines, so "other" is reported as "". +export const getLogLineStatus = (logLine = "") => { + const level = classifyLogLine(logLine); + return level === "other" ? "" : level; }; -export const transformLogStringForTimeBudget = (t, options) => { - return options - ? new Date( - new Date().setTime( - new Date(t?.at(0)?.slice(0, 22)?.replace("T", " "))?.getTime() - ) - ) - .toLocaleString("en-US", { - month: "short", - day: "2-digit", - year: "numeric", - }) - .replace(",", "") - : new Date( - new Date().setTime( - new Date(t?.at(0)?.slice(0, 19)?.replace("T", " "))?.getTime() - - timezoneOffsetSec * 1000 - ) - ) - .toLocaleString("en-US", { - month: "short", - day: "2-digit", - year: "numeric", - }) - .replace(",", ""); +// Milliseconds are what distinguish two adjacent rows, so they must survive; and +// the zone offset has to come from the timestamp itself, not from module-load +// time, or anything across a DST boundary is an hour out. +const parseLogTime = (t) => { + const raw = t?.at?.(0); + if (!raw) { + return null; + } + const date = new Date(raw); + return Number.isNaN(date.getTime()) ? null : date; +}; + +const zoneOf = (utc) => (utc ? "UTC" : undefined); + +export const transformLogString = (t, utc) => { + const date = parseLogTime(t); + if (!date) { + return ""; + } + return date + .toLocaleString("sv-SE", { + timeZone: zoneOf(utc), + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, + }) + .replace(",", "."); +}; + +export const transformLogStringForTimeBudget = (t, utc) => { + const date = parseLogTime(t); + if (!date) { + return ""; + } + return date + .toLocaleString("en-US", { + timeZone: zoneOf(utc), + month: "short", + day: "2-digit", + year: "numeric", + }) + .replace(",", ""); }; export const getLogs = async function ({ diff --git a/application/frontend/src/Views/Logs/shareLinkViewState.js b/application/frontend/src/Views/Logs/shareLinkViewState.js index fbbea65..d1e9f78 100644 --- a/application/frontend/src/Views/Logs/shareLinkViewState.js +++ b/application/frontend/src/Views/Logs/shareLinkViewState.js @@ -13,3 +13,44 @@ export const shouldFlushBufferedLogs = ( isSharedLinkFocusMode = false, releaseRequested = false ) => logsFromWSLength > 0 && (!isSharedLinkFocusMode || releaseRequested); + +// Svelte invalidates an object when any of its members is assigned, so plain +// fields cannot carry state between reactive blocks without retriggering them. +// Closure variables behind function calls can. +export function createLogsViewFlags() { + let deepLinkPending = false; + let skipSearchReload = false; + let skipStatusReload = false; + + return { + beginDeepLink() { + deepLinkPending = true; + // The reset of searchText/chosenStatus that follows retriggers the search + // and status blocks; that reload must not overwrite the deep-link window. + skipSearchReload = true; + skipStatusReload = true; + }, + endDeepLink() { + deepLinkPending = false; + }, + isDeepLinkPending() { + return deepLinkPending; + }, + skipNextSearchReload() { + skipSearchReload = true; + }, + skipNextStatusReload() { + skipStatusReload = true; + }, + consumeSearchSkip() { + const skip = skipSearchReload; + skipSearchReload = false; + return skip; + }, + consumeStatusSkip() { + const skip = skipStatusReload; + skipStatusReload = false; + return skip; + }, + }; +} diff --git a/application/frontend/src/Views/Logs/timestamps.test.mjs b/application/frontend/src/Views/Logs/timestamps.test.mjs new file mode 100644 index 0000000..457e9d7 --- /dev/null +++ b/application/frontend/src/Views/Logs/timestamps.test.mjs @@ -0,0 +1,61 @@ +process.env.TZ = "Europe/Kyiv"; + +import assert from "node:assert/strict"; +import { + bundleComponent, + installDom, + importBundle, +} from "../../../test/harness.mjs"; + +installDom({}); +const { transformLogString, transformLogStringForTimeBudget } = + await importBundle(await bundleComponent("test/entry.js")); + +const row = (ts) => [ts, "a message", ts + " +1-1"]; + +function run() { + const summer = "2026-07-15T12:44:03.567891234Z"; + const winter = "2026-01-15T12:44:03.567891234Z"; + + // Milliseconds must survive: without them, two distinct rows look identical + // and a genuine duplicate is indistinguishable from two adjacent lines. + assert.equal( + transformLogString(row(summer), true), + "12:44:03.567", + "UTC mode truncates the millisecond" + ); + assert.equal( + transformLogString(row(summer), false), + "15:44:03.567", + "local mode loses the millisecond" + ); + + const a = transformLogString(row("2026-07-15T12:44:03.560000000Z"), false); + const b = transformLogString(row("2026-07-15T12:44:03.561000000Z"), false); + assert.notEqual(a, b, `rows 1ms apart render identically: ${a} == ${b}`); + + // Europe/Kyiv is UTC+3 in July and UTC+2 in January. A single offset captured + // at module load makes everything across a DST boundary an hour wrong. + assert.equal( + transformLogString(row(winter), false), + "14:44:03.567", + "the timezone offset is not computed per timestamp" + ); + + assert.equal(transformLogStringForTimeBudget(row(summer), true), "Jul 15 2026"); + assert.equal(transformLogStringForTimeBudget(row(winter), false), "Jan 15 2026"); + + // A date that falls on the previous day in UTC but the next locally. + const lateEvening = "2026-07-15T22:30:00.000000000Z"; + assert.equal(transformLogStringForTimeBudget(row(lateEvening), true), "Jul 15 2026"); + assert.equal(transformLogStringForTimeBudget(row(lateEvening), false), "Jul 16 2026"); + + // Degenerate input must not throw. + assert.equal(transformLogString(undefined, true), ""); + assert.equal(transformLogString(["not a date", "m"], true), ""); + assert.equal(transformLogStringForTimeBudget(undefined, true), ""); + + console.log("timestamp rendering tests passed"); +} + +run(); diff --git a/application/frontend/src/Views/Main/Main.svelte b/application/frontend/src/Views/Main/Main.svelte index 861337f..3d3bb21 100644 --- a/application/frontend/src/Views/Main/Main.svelte +++ b/application/frontend/src/Views/Main/Main.svelte @@ -101,10 +101,10 @@ if (Array.isArray(data) && data.at(0)) { hostList = [...data]; } - if (data.host) { + if (data?.host) { hostList = [data]; } - return data; + return Array.isArray(data) ? data : []; } onMount(async () => { const data = await getHosts(); @@ -121,18 +121,18 @@ return s.serviceName === $lastChosenService; } ); - if (isAlreadyChosenService[0].serviceName) { + if (isAlreadyChosenService[0]?.serviceName) { return; } else { if (service) { lastChosenService.set(service); } else { - lastChosenService.set(hostList.at(0)["services"].at(0).serviceName); + lastChosenService.set(hostList.at(0)?.services?.at(0)?.serviceName); } if (host) { lastChosenHost.set(host); } else { - lastChosenHost.set(hostList.at(0)["host"]); + lastChosenHost.set(hostList.at(0)?.host); } } } diff --git a/application/frontend/src/lib/ListWithChoise/ListWithChoise.svelte b/application/frontend/src/lib/ListWithChoise/ListWithChoise.svelte index 5ae8482..e35e87e 100644 --- a/application/frontend/src/lib/ListWithChoise/ListWithChoise.svelte +++ b/application/frontend/src/lib/ListWithChoise/ListWithChoise.svelte @@ -35,7 +35,7 @@ $lastChosenHost || (sortedData[0] && sortedData[0].host); const chosenService = $lastChosenService || - (sortedData[0] && sortedData[0].services[0].serviceName); + sortedData[0]?.services?.[0]?.serviceName; activeElementName = sortedData[0] && `${chosenHost}-${chosenService}`; lastChosenHost.set(chosenHost); @@ -281,7 +281,7 @@ }} > -

stoped services

+

stopped services

+ {#if secretError} +

{secretError}

+ {/if} {#if $currentSnippedOption === "Docker"} {/if} diff --git a/application/frontend/src/utils/fetch.js b/application/frontend/src/utils/fetch.js index ea856c2..e76d1e9 100644 --- a/application/frontend/src/utils/fetch.js +++ b/application/frontend/src/utils/fetch.js @@ -36,6 +36,9 @@ class fetchApi { navigate(`${changeKey}/login`, { replace: true }); return null; } + if (!response.ok) { + throw new Error(`${method} ${path} failed with ${response.status}`); + } return await response.json(); } @@ -75,16 +78,18 @@ class fetchApi { hostName = "", signal, }) { - return await this.doFetch( - "GET", - `${ - this.url - }getLogs?host=${hostName}&id=${containerName}&search=${search}&status=${status}&limit=${limit}&startWith=${startWith}${ - search ? `&caseSens=${caseSens}` : "" - }`, - null, - signal - ); + const params = new URLSearchParams({ + host: hostName, + id: containerName, + search, + status, + limit, + startWith, + }); + if (search) { + params.set("caseSens", caseSens); + } + return await this.doFetch("GET", `${this.url}getLogs?${params}`, null, signal); } async getPrevLogs({ @@ -97,10 +102,17 @@ class fetchApi { startWith = "", hostName = "", }) { - return await this.doFetch( - "GET", - `${this.url}getPrevLogs?host=${hostName}&id=${containerName}&search=${search}&status=${status}&limit=${limit}&offset=${offset}&startWith=${startWith}&caseSens=${caseSens}` - ); + const params = new URLSearchParams({ + host: hostName, + id: containerName, + search, + status, + limit, + offset, + startWith, + caseSens, + }); + return await this.doFetch("GET", `${this.url}getPrevLogs?${params}`); } async getUsers() { @@ -126,10 +138,8 @@ class fetchApi { return await this.doFetch("GET", `${this.url}getSizeByAll`); } async getServiceLogsSize(host, service) { - return await this.doFetch( - "GET", - `${this.url}getSizeByService?host=${host}&service=${service}` - ); + const params = new URLSearchParams({ host, service }); + return await this.doFetch("GET", `${this.url}getSizeByService?${params}`); } async cleanLogs(host, service) { return await this.doFetch("POST", `${this.url}deleteContainerLogs`, { @@ -175,10 +185,13 @@ class fetchApi { startWith = "", hostName = "", }) { - return await this.doFetch( - "GET", - `${this.url}getLogWithPrev?host=${hostName}&id=${containerName}&limit=${limit}&startWith=${startWith}` - ); + const params = new URLSearchParams({ + host: hostName, + id: containerName, + limit, + startWith, + }); + return await this.doFetch("GET", `${this.url}getLogWithPrev?${params}`); } async cleanDockerLogs(host, service) { @@ -199,10 +212,14 @@ class fetchApi { } async getLogsByTag({ host, containerName, limit, status, message }) { - return await this.doFetch( - "GET", - `${this.url}getUserSettings?host=${host}&id=${containerName}&limit=${limit}&status=${status}&message=${message}` - ); + const params = new URLSearchParams({ + host, + id: containerName, + limit, + status, + message, + }); + return await this.doFetch("GET", `${this.url}getUserSettings?${params}`); } } diff --git a/application/frontend/src/utils/fetch.test.mjs b/application/frontend/src/utils/fetch.test.mjs new file mode 100644 index 0000000..55613bc --- /dev/null +++ b/application/frontend/src/utils/fetch.test.mjs @@ -0,0 +1,74 @@ +// Pagination cursors contain " +", and in a query string "+" means space. Built +// by interpolation, the cursor never survived the round trip. +import assert from "node:assert/strict"; +import { + bundleComponent, + installDom, + importBundle, +} from "../../test/harness.mjs"; + +installDom({}); +const { FetchApi } = await importBundle(await bundleComponent("test/entry.js")); + +const CURSOR = "2026-11-07T12:44:07.370000000Z +1730980000000000000-42"; +const SEARCH = "a+b&status=error#frag c"; + +function capturing() { + const seen = []; + globalThis.fetch = async (url) => { + seen.push(String(url)); + return { status: 200, ok: true, json: async () => ({ logs: [], is_end: true }) }; + }; + return seen; +} + +function queryOf(url) { + return new URLSearchParams(url.slice(url.indexOf("?") + 1)); +} + +async function run() { + const api = new FetchApi(); + + let seen = capturing(); + await api.getLogs({ + containerName: "my container", + hostName: "my host", + search: SEARCH, + startWith: CURSOR, + limit: 30, + status: "error", + caseSens: true, + }); + let params = queryOf(seen[0]); + assert.equal(params.get("startWith"), CURSOR, `getLogs mangled the cursor: ${seen[0]}`); + assert.equal(params.get("search"), SEARCH, "getLogs mangled the search term"); + assert.equal(params.get("id"), "my container"); + assert.equal(params.get("host"), "my host"); + assert.equal(params.get("status"), "error"); + + seen = capturing(); + await api.getPrevLogs({ + containerName: "c", + hostName: "h", + search: SEARCH, + startWith: CURSOR, + }); + params = queryOf(seen[0]); + assert.equal(params.get("startWith"), CURSOR, `getPrevLogs mangled the cursor: ${seen[0]}`); + assert.equal(params.get("search"), SEARCH, "getPrevLogs mangled the search term"); + + seen = capturing(); + await api.getLogsWithPrev({ containerName: "c", hostName: "h", startWith: CURSOR }); + params = queryOf(seen[0]); + assert.equal(params.get("startWith"), CURSOR, `getLogWithPrev mangled the cursor: ${seen[0]}`); + + seen = capturing(); + await api.getServiceLogsSize("host with space", "service&name"); + params = queryOf(seen[0]); + assert.equal(params.get("host"), "host with space"); + assert.equal(params.get("service"), "service&name"); + + console.log("fetch query-building tests passed"); +} + +await run(); diff --git a/application/frontend/test/entry.js b/application/frontend/test/entry.js index 26189a1..057c5ee 100644 --- a/application/frontend/test/entry.js +++ b/application/frontend/test/entry.js @@ -1,4 +1,11 @@ export { default as NewLogsV2 } from "../src/Views/Logs/NewLogsV2.svelte"; export { default as LogsViewHeder } from "../src/Views/Logs/LogsViewHeder/LogsViewHeder.svelte"; +export { default as FetchApi } from "../src/utils/fetch.js"; export * from "../src/Stores/stores.js"; +export { + transformLogString, + transformLogStringForTimeBudget, + getLogLineStatus, + classifyLogLine, +} from "../src/Views/Logs/functions.js"; export { tick } from "svelte"; From b6b7ad857f62f51215d8a359722cc14061ea01c2 Mon Sep 17 00:00:00 2001 From: Roman Malenko Date: Fri, 7 Aug 2026 22:03:49 +0300 Subject: [PATCH 06/31] fix(favourites)!: key favourites by the user who set them --- README.md | 26 +++++- .../backend/app/containerdb/containerdb.go | 15 ++-- .../backend/app/containerdb/limit_test.go | 35 ++++++++ .../backend/app/containerdb/retention_test.go | 74 ++++++++++++++++ application/backend/app/daemon/daemon.go | 35 +++++--- application/backend/app/daemon/pipe_test.go | 77 +++++++++++++++++ .../backend/app/routes/edituser_test.go | 38 ++++++++- application/backend/app/routes/routes.go | 51 ++++++++--- .../backend/app/statistics/statistics.go | 4 +- application/backend/app/userdb/userdb.go | 22 +++-- application/backend/app/util/util.go | 43 ++++++++++ application/backend/app/vars/state.go | 30 ++++++- application/backend/main.go | 6 ++ application/frontend/package-lock.json | 1 + application/frontend/package.json | 1 + application/frontend/src/Stores/stores.js | 3 +- .../frontend/src/Views/Logs/NewLogsV2.svelte | 42 ++++++--- .../src/Views/Logs/NewLogsV2.test.mjs | 85 +++++++++++++++++++ .../frontend/src/Views/Main/Main.svelte | 6 +- .../frontend/src/lib/CheckBox/Checkbox.svelte | 2 +- .../src/lib/ClientPanel/ClientPanel.svelte | 3 +- .../ConfirmationMenu/ConfirmationMenu.svelte | 16 +++- .../lib/ListWithChoise/ListWithChoise.svelte | 2 +- .../frontend/src/lib/LogsSize/LogsSize.svelte | 10 ++- .../frontend/src/lib/Toast/Toast.svelte | 4 +- 25 files changed, 567 insertions(+), 64 deletions(-) create mode 100644 application/backend/app/containerdb/retention_test.go create mode 100644 application/backend/app/daemon/pipe_test.go diff --git a/README.md b/README.md index ec66480..f459f7d 100644 --- a/README.md +++ b/README.md @@ -85,14 +85,34 @@ Once done, just go to and login as "admin" with . | ADMIN_USERNAME | Username for initial user | `admin` | if `AGENT=false` | ADMIN_PASSWORD | Password for initial user. Must not be empty — OnLogs refuses to start without it unless `DISABLE_AUTH=true` | | if `AGENT=false` | PORT | Port to listen on | `2874` | if `AGENT=false` -| JWT_SECRET | Secret for JWT tokens for users | Generates randomly | - +| JWT_SECRET | Secret for JWT tokens for users. Generated with `crypto/rand` on first start and persisted to `leveldb/JWT_secret`; OnLogs refuses to start if it is set to an empty value | Generates randomly | - | ONLOGS_PATH_PREFIX | Base path if you using OnLogs not on subdomain | | only if using on path prefix -| AGENT | Toggles agent mode. If enabled, there will be no web interface available, and all logs will be sent and stored on HOST | `false` | - +| AGENT | Toggles agent mode. If enabled, there will be no web interface available, and all logs will be sent and stored on HOST. Parsed as a boolean, so `false`, `0` and an unset value all mean off | `false` | - | HOST | Url to OnLogs host from protocol to domain name. | | if `AGENT=true` | ONLOGS_TOKEN | Token that will use an agent to authorize and connect to HOST | Generates with OnLogs interface | if `AGENT=true` -| MAX_LOGS_SIZE | Maximum allowed total logs size before cleanup triggers. Accepts human-readable formats like 5GB, 500MB, 1.5GB etc. When exceeded, 10% of logs (by count) will be removed proportionally across containers starting from oldest | 10GB | - +| MAX_LOGS_SIZE | Maximum allowed total logs size before cleanup triggers. Accepts human-readable formats like 5GB, 500MB, 1.5GB etc. When exceeded, 10% of logs (by count) will be removed proportionally across containers starting from oldest. Validated at startup: an unparseable value stops OnLogs rather than silently disabling retention | 10GB | - | DISABLE_AUTH | Option to completely disable built in authentication in the application. When this option is set to `true` the app will behave like if the Administrator is logged in. The option to manage users will be removed. | false | - +### Upgrading + +Three changes affect existing deployments: + +- **Passwords are hashed.** Accounts created before the upgrade keep working and + are converted to a hash the next time they log in successfully. Passwords + already on disk stay in cleartext until that happens, so rotate them if the + database may have been exposed. +- **Sessions are checked against the user database.** Deleting a user now revokes + their session immediately instead of leaving it valid for 48 hours. +- **A blank `ADMIN_PASSWORD` or `JWT_SECRET` stops the server.** Both previously + produced a deployment that anyone could log into as admin. Set them, or set + `DISABLE_AUTH=true` if you deliberately run without authentication. + +**Favourites are now per user.** They were stored under `host/service` with no +username, so one person starring a container starred it for everyone. Existing +stars are not migrated — they need setting again once, per user. + +Agent tokens issued before the upgrade continue to work. + ### Docket socket URL By default the app will connect using the raw unix socket. But this can be overriden via the ENV variable `DOCKER_HOST`. That way you can specify fully qualified URL to the socket or URL of an docker socket proxy. diff --git a/application/backend/app/containerdb/containerdb.go b/application/backend/app/containerdb/containerdb.go index 9ef7254..8b51040 100644 --- a/application/backend/app/containerdb/containerdb.go +++ b/application/backend/app/containerdb/containerdb.go @@ -144,8 +144,13 @@ func checkAndManageLogSize(host string, container string) error { logsDB.CompactRange(leveldbUtil.Range{Start: nil, Limit: nil}) } - statusesDB := util.GetDB(hostName, containerName, "statuses") - if statusesDB != nil { + // Prune everything the quota measures, or the measurement can + // never come down. + for _, dbType := range []string{"statuses", "statistics"} { + statusesDB := util.GetDB(hostName, containerName, dbType) + if statusesDB == nil { + continue + } batch := new(leveldb.Batch) deletedCountStatuses := 0 iter := statusesDB.NewIterator(nil, nil) @@ -166,7 +171,7 @@ func checkAndManageLogSize(host string, container string) error { if deletedCountStatuses > 0 { err := statusesDB.Write(batch, nil) if err != nil { - fmt.Printf("Failed to delete batch in statusesDB for %s/%s: %v\n", hostName, containerName, err) + fmt.Printf("Failed to delete batch in %s for %s/%s: %v\n", dbType, hostName, containerName, err) } statusesDB.CompactRange(leveldbUtil.Range{Start: nil, Limit: nil}) } @@ -408,14 +413,14 @@ func GetLogs(getPrev bool, include bool, host string, container string, message limit = maxLogsPerRequest } - logs_db := util.GetDB(host, container, "logs") + logs_db := util.GetDBIfExists(host, container, "logs") if logs_db == nil { return map[string]interface{}{"logs": [][]string{}, "last_processed_key": "", "is_end": true} } var statusDb *leveldb.DB if status != nil { - statusDb = util.GetDB(host, container, "statuses") + statusDb = util.GetDBIfExists(host, container, "statuses") } iter := logs_db.NewIterator(nil, nil) defer iter.Release() diff --git a/application/backend/app/containerdb/limit_test.go b/application/backend/app/containerdb/limit_test.go index 67022b0..1490c99 100644 --- a/application/backend/app/containerdb/limit_test.go +++ b/application/backend/app/containerdb/limit_test.go @@ -117,3 +117,38 @@ func TestGetLogsResumesPastTheScanCap(t *testing.T) { } } } + +// GetDB opens-or-creates and caches forever, so a read route taking a raw +// container name let any authenticated GET allocate a LevelDB tree and pin its +// file descriptors — one per distinct name, unbounded. +func TestGetLogsDoesNotCreateADatabaseForAnUnknownContainer(t *testing.T) { + t.Chdir(t.TempDir()) + + if err := os.MkdirAll("leveldb/hosts/RealHost/containers/realcontainer", 0o700); err != nil { + t.Fatal(err) + } + + for i := 0; i < 5; i++ { + name := "no-such-container-" + strconv.Itoa(i) + result := GetLogs(false, false, "RealHost", name, "", 30, "", false, nil) + if rows := result["logs"].([][]string); len(rows) != 0 { + t.Fatalf("%s returned %d rows", name, len(rows)) + } + } + GetLogs(false, false, "NoSuchHostAtAll", "whatever", "", 30, "", false, nil) + + entries, err := os.ReadDir("leveldb/hosts/RealHost/containers") + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name() != "realcontainer" { + names := []string{} + for _, e := range entries { + names = append(names, e.Name()) + } + t.Fatalf("reading unknown containers created databases: %v", names) + } + if _, err := os.Stat("leveldb/hosts/NoSuchHostAtAll"); err == nil { + t.Error("reading an unknown host created a host directory") + } +} diff --git a/application/backend/app/containerdb/retention_test.go b/application/backend/app/containerdb/retention_test.go new file mode 100644 index 0000000..a853145 --- /dev/null +++ b/application/backend/app/containerdb/retention_test.go @@ -0,0 +1,74 @@ +package containerdb + +import ( + "os" + "testing" + + "github.com/devforth/OnLogs/app/util" + "github.com/devforth/OnLogs/app/vars" +) + +// Retention measured the whole container directory but deleted from only two of +// its six sub-databases, so data it cannot touch pushed it into stripping the +// data it can down to nothing. +func TestRetentionMeasuresOnlyWhatItCanPrune(t *testing.T) { + host, container := "RetentionHost", "RetentionContainer" + base := "leveldb/hosts/" + host + "/containers/" + container + _ = os.RemoveAll("leveldb/hosts/" + host) + t.Cleanup(func() { _ = os.RemoveAll("leveldb/hosts/" + host) }) + + vars.Mutex.Lock() + delete(vars.Container_Stat_Counter, host+"/"+container) + vars.Mutex.Unlock() + + logsDB := util.GetDB(host, container, "logs") + if logsDB == nil { + t.Fatal("could not open the logs database") + } + for i := 0; i < 5; i++ { + ts := vars.Year + "-02-10T12:5" + string(rune('0'+i)) + ":09.230421754Z" + if err := PutLogMessage(logsDB, host, container, []string{ts, "a log line"}); err != nil { + t.Fatal(err) + } + } + + withLogsOnly := util.GetPrunableSize(host, container) + if withLogsOnly == 0 { + t.Fatal("prunable size is zero even though logs were written") + } + + // Something retention cannot delete from: a large broken-logs buffer. + if err := os.MkdirAll(base+"/brokenlogs", 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(base+"/brokenlogs/ballast", make([]byte, 8*1024*1024), 0o600); err != nil { + t.Fatal(err) + } + + afterBallast := util.GetPrunableSize(host, container) + if afterBallast != withLogsOnly { + t.Errorf("a sub-database retention cannot prune changed the measured size: %v -> %v", + withLogsOnly, afterBallast) + } + + // The whole-directory measure does see it, which is what made the quota + // unreachable when retention used it. + if util.GetDirSize(host, container) <= afterBallast { + t.Error("GetDirSize should still report the full directory, for the size display") + } +} + +func TestPrunableDBsCoverEverythingRetentionMeasures(t *testing.T) { + prunable := util.PrunableDBs() + for _, expected := range []string{"logs", "statuses", "statistics"} { + found := false + for _, actual := range prunable { + if actual == expected { + found = true + } + } + if !found { + t.Errorf("%q is measured by retention but never pruned", expected) + } + } +} diff --git a/application/backend/app/daemon/daemon.go b/application/backend/app/daemon/daemon.go index f586dcf..75c5010 100644 --- a/application/backend/app/daemon/daemon.go +++ b/application/backend/app/daemon/daemon.go @@ -129,15 +129,6 @@ func (h *DaemonService) rememberFingerprint(containerName, fingerprint string) { } } -// forgetContainer releases the dedupe state for a container whose stream ended. -func (h *DaemonService) forgetContainer(containerName string) { - h.streamsMu.Lock() - defer h.streamsMu.Unlock() - delete(h.recentSet, containerName) - delete(h.recentFingerprints, containerName) - delete(h.droppedReplays, containerName) -} - // seedBoundaryFingerprints loads the lines already stored at exactly the cursor // timestamp, so a replay of them is recognised while a genuinely new line // sharing that nanosecond is still kept. @@ -312,12 +303,22 @@ func (h *DaemonService) streamDockerLogs(ctx context.Context, rc io.ReadCloser, _ = pw.CloseWithError(err) }() - if scanErr := scanLogs(ctx, pr, onLine); scanErr != nil { - return scanErr - } + scanErr := scanLogs(ctx, pr, onLine) + + // Release the writer before waiting on it. StdCopy may be parked in + // pw.Write, and neither closing rc nor cancelling ctx unblocks a blocked + // write -- so without this, an early return from scanLogs (cancellation, or + // a line over the scanner's 1 MiB limit) leaves StdCopy stuck, this function + // never returns, finalizeStream never runs, and EnsureStream refuses to + // restart that container for the life of the process. + _ = pr.CloseWithError(io.ErrClosedPipe) copyErr := <-copyDone - if copyErr != nil && !errors.Is(copyErr, io.EOF) && !errors.Is(copyErr, context.Canceled) { + if scanErr != nil { + return scanErr + } + if copyErr != nil && !errors.Is(copyErr, io.EOF) && !errors.Is(copyErr, context.Canceled) && + !errors.Is(copyErr, io.ErrClosedPipe) { return copyErr } return nil @@ -344,8 +345,16 @@ func (h *DaemonService) finalizeStream(containerName string, streamID uint64) bo return false } + // Dropping the CancelFunc without calling it leaks streamCtx and the + // watchdog goroutine parked on <-ctx.Done() for every stream that ends. + if cancel, ok := h.streamCancels[containerName]; ok { + defer cancel() + } delete(h.streamCancels, containerName) delete(h.streamIDs, containerName) + delete(h.recentSet, containerName) + delete(h.recentFingerprints, containerName) + delete(h.droppedReplays, containerName) return true } diff --git a/application/backend/app/daemon/pipe_test.go b/application/backend/app/daemon/pipe_test.go new file mode 100644 index 0000000..315bcc3 --- /dev/null +++ b/application/backend/app/daemon/pipe_test.go @@ -0,0 +1,77 @@ +package daemon + +import ( + "context" + "io" + "strings" + "testing" + "time" +) + +// A reader that never reaches EOF, so StdCopy keeps producing and eventually +// parks in pw.Write once the consumer stops reading. +type endlessReader struct{ frame []byte } + +func (r *endlessReader) Read(p []byte) (int, error) { + n := copy(p, r.frame) + return n, nil +} +func (r *endlessReader) Close() error { return nil } + +func dockerFrame(payload string) []byte { + header := []byte{1, 0, 0, 0, 0, 0, 0, byte(len(payload))} + return append(header, []byte(payload)...) +} + +// Cancelling the context must not leave streamDockerLogs waiting on a StdCopy +// that is parked writing into a pipe nobody reads. If it does, the stream never +// finalises and EnsureStream refuses to restart that container ever again. +func TestStreamDockerLogsReturnsWhenCancelledMidWrite(t *testing.T) { + ctrl := &DaemonService{} + ctx, cancel := context.WithCancel(context.Background()) + + reader := &endlessReader{frame: dockerFrame("2026-01-01T00:00:00.000000000Z x\n")} + + done := make(chan error, 1) + go func() { + done <- ctrl.streamDockerLogs(ctx, reader, func(string) {}, true) + }() + + time.Sleep(50 * time.Millisecond) + cancel() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("streamDockerLogs never returned after cancellation; the container's stream is wedged for the life of the process") + } +} + +// A single line larger than the scanner's buffer must end the stream rather than +// wedge it, so the reconcile loop can re-attach. +func TestStreamDockerLogsReturnsOnAnOversizedLine(t *testing.T) { + ctrl := &DaemonService{} + + huge := "2026-01-01T00:00:00.000000000Z " + strings.Repeat("a", 2*1024*1024) + reader := io.NopCloser(strings.NewReader(string(dockerFrameLarge(huge)))) + + done := make(chan error, 1) + go func() { + done <- ctrl.streamDockerLogs(context.Background(), reader, func(string) {}, true) + }() + + select { + case err := <-done: + if err == nil { + t.Log("oversized line was tolerated") + } + case <-time.After(5 * time.Second): + t.Fatal("streamDockerLogs never returned on an oversized line") + } +} + +func dockerFrameLarge(payload string) []byte { + n := len(payload) + header := []byte{1, 0, 0, 0, byte(n >> 24), byte(n >> 16), byte(n >> 8), byte(n)} + return append(header, []byte(payload)...) +} diff --git a/application/backend/app/routes/edituser_test.go b/application/backend/app/routes/edituser_test.go index 6f32e47..3e92178 100644 --- a/application/backend/app/routes/edituser_test.go +++ b/application/backend/app/routes/edituser_test.go @@ -75,12 +75,15 @@ func TestGetHostsReadsFavouritesPerHost(t *testing.T) { os.RemoveAll("leveldb/hosts") os.MkdirAll("leveldb/hosts/FavHostA/containers/shared", 0o700) os.MkdirAll("leveldb/hosts/FavHostB/containers/shared", 0o700) + + // Favourites are per user, so the key carries the viewer's name. + starred := favouriteKey("someuser", "FavHostB", "shared") t.Cleanup(func() { - vars.FavsDB.Delete([]byte("FavHostB/shared"), nil) + vars.FavsDB.Delete(starred, nil) os.RemoveAll("leveldb/hosts") }) - if err := vars.FavsDB.Put([]byte("FavHostB/shared"), nil, nil); err != nil { + if err := vars.FavsDB.Put(starred, nil, nil); err != nil { t.Fatal(err) } @@ -114,3 +117,34 @@ func TestGetHostsReadsFavouritesPerHost(t *testing.T) { t.Error("a container that was never starred on FavHostA is reported as a favourite") } } + +// One user's star used to flip everyone's: favourites were keyed by +// host/service only, while the user settings stored beside them are per user. +func TestFavouritesAreScopedToTheUserWhoSetThem(t *testing.T) { + ctrl := initTestConfig() + userdb.CreateUser("favuser", "favpass") + userdb.CreateUser("otheruser", "otherpass") + t.Cleanup(func() { + userdb.DeleteUser("favuser", "") + userdb.DeleteUser("otheruser", "") + vars.FavsDB.Delete(favouriteKey("favuser", "h", "svc"), nil) + vars.FavsDB.Delete(favouriteKey("otheruser", "h", "svc"), nil) + }) + + body, _ := json.Marshal(map[string]string{"host": "h", "service": "svc"}) + req, _ := http.NewRequest("POST", "/api/v1/changeFavorite", bytes.NewBuffer(body)) + req.AddCookie(&http.Cookie{Name: "onlogs-cookie", Value: util.CreateJWT("favuser")}) + rr := httptest.NewRecorder() + http.HandlerFunc(ctrl.ChangeFavourite).ServeHTTP(rr, req) + + if code := rr.Result().StatusCode; code != http.StatusOK { + t.Fatalf("changeFavorite returned %d: %s", code, rr.Body.String()) + } + + if starred, _ := vars.FavsDB.Has(favouriteKey("favuser", "h", "svc"), nil); !starred { + t.Error("the user's own favourite was not recorded") + } + if starred, _ := vars.FavsDB.Has(favouriteKey("otheruser", "h", "svc"), nil); starred { + t.Error("one user's star was applied to another user") + } +} diff --git a/application/backend/app/routes/routes.go b/application/backend/app/routes/routes.go index 3e81dde..4125b89 100644 --- a/application/backend/app/routes/routes.go +++ b/application/backend/app/routes/routes.go @@ -258,15 +258,24 @@ func (h *RouteController) ChangeFavourite(w http.ResponseWriter, req *http.Reque return } - key := []byte(container.Host + "/" + container.Service) - isAlreadyFavourite, _ := vars.FavsDB.Has(key, nil) - if isAlreadyFavourite { - vars.FavsDB.Delete(key, nil) - } else { - vars.FavsDB.Put(key, nil, nil) + username, _ := util.GetUserFromJWT(*req) + key := favouriteKey(username, container.Host, container.Service) + + isAlreadyFavourite, err := vars.FavsDB.Has(key, nil) + if err == nil { + if isAlreadyFavourite { + err = vars.FavsDB.Delete(key, nil) + } else { + err = vars.FavsDB.Put(key, nil, nil) + } } w.Header().Add("Content-Type", "application/json") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) } @@ -301,7 +310,9 @@ func (h *RouteController) GetChartData(w http.ResponseWriter, req *http.Request) if !util.Contains(data.Unit, []string{"hour", "day", "month"}) { w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]interface{}{"error": "Invalid data!"}) + return } w.Header().Add("Content-Type", "application/json") @@ -323,6 +334,7 @@ func (h *RouteController) GetHosts(w http.ResponseWriter, req *http.Request) { } to_return := []HostsList{} + viewer, _ := util.GetUserFromJWT(*req) ctx := req.Context() activeContainers := h.DaemonService.GetContainersList(ctx) @@ -331,7 +343,7 @@ func (h *RouteController) GetHosts(w http.ResponseWriter, req *http.Request) { containers, _ := os.ReadDir("leveldb/hosts/" + host.Name() + "/containers") allContainers := []map[string]interface{}{} for _, container := range containers { - isFavorite, _ := vars.FavsDB.Has([]byte(host.Name()+"/"+container.Name()), nil) + isFavorite, _ := vars.FavsDB.Has(favouriteKey(viewer, host.Name(), container.Name()), nil) if util.Contains(container.Name(), activeContainers) || util.Contains(container.Name(), vars.AgentContainers(host.Name())) { allContainers = append(allContainers, map[string]interface{}{"serviceName": container.Name(), "isDisabled": false, "isFavorite": isFavorite}) } else { @@ -403,14 +415,21 @@ func (h *RouteController) GetDockerSize(w http.ResponseWriter, req *http.Request } if params.Get("host") == "" { - panic("Host is not mentioned!") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Host is not mentioned!"}) + return } w.Header().Add("Content-Type", "application/json") + // A container with no docker json log has size 0, not a nil dereference. + var size float64 containerID := util.GetDockerContainerID(params.Get("host"), params.Get("service")) - info, _ := os.Stat("/var/lib/docker/containers/" + containerID + "/" + containerID + "-json.log") + if containerID != "" { + if info, err := os.Stat("/var/lib/docker/containers/" + containerID + "/" + containerID + "-json.log"); err == nil && info != nil { + size = float64(info.Size()) / (1024.0 * 1024.0) + } + } - size := float64(info.Size()) / (1024.0 * 1024.0) if size < 0.1 && size != 0.0 { size = 0.1 } @@ -457,6 +476,11 @@ func (h *RouteController) GetStorageData(w http.ResponseWriter, req *http.Reques json.NewEncoder(w).Encode(util.GetStorageData()) } +// Favourites are per user, like the user settings stored next to them. +func favouriteKey(username string, host string, service string) []byte { + return []byte(username + "\x00" + host + "/" + service) +} + func statusFilter(params url.Values) *string { status := params.Get("status") if status == "" { @@ -673,7 +697,12 @@ func (h *RouteController) UpdateUserSettings(w http.ResponseWriter, req *http.Re return } username, _ := util.GetUserFromJWT(*req) - userdb.UpdateUserSettings(username, settings) + w.Header().Add("Content-Type", "application/json") + if err := userdb.UpdateUserSettings(username, settings); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) } diff --git a/application/backend/app/statistics/statistics.go b/application/backend/app/statistics/statistics.go index be44650..ea87366 100644 --- a/application/backend/app/statistics/statistics.go +++ b/application/backend/app/statistics/statistics.go @@ -168,7 +168,7 @@ func GetStatisticsByService(host string, service string, value int) map[string]u searchTo := time.Now().Add(-(time.Hour * time.Duration(value/2))).UTC() var tmp_stats map[string]uint64 - current_db := util.GetDB(host, service, "statistics") + current_db := util.GetDBIfExists(host, service, "statistics") if current_db == nil { return to_return } @@ -214,7 +214,7 @@ func GetChartData(host string, service string, unit string, uAmount int) map[str location := host + "/" + service to_return := map[string]map[string]uint64{} - statsDB := util.GetDB(host, service, "statistics") + statsDB := util.GetDBIfExists(host, service, "statistics") if statsDB == nil { return to_return } diff --git a/application/backend/app/userdb/userdb.go b/application/backend/app/userdb/userdb.go index db8c23c..5e4150b 100644 --- a/application/backend/app/userdb/userdb.go +++ b/application/backend/app/userdb/userdb.go @@ -54,8 +54,7 @@ func DeleteUser(login string, password string) error { return errors.New("No such user") } - vars.UsersDB.Delete([]byte(login), nil) - return nil + return vars.UsersDB.Delete([]byte(login), nil) } func CheckUserPassword(login string, gotPassword string) bool { @@ -83,6 +82,10 @@ func CheckUserPassword(login string, gotPassword string) bool { func GetUserSettings(username string) map[string]interface{} { var to_return map[string]interface{} + if vars.SettingsDB == nil { + return to_return + } + vars.Mutex.Lock() result, _ := vars.SettingsDB.Get([]byte(username), nil) vars.Mutex.Unlock() @@ -90,9 +93,16 @@ func GetUserSettings(username string) map[string]interface{} { return to_return } -func UpdateUserSettings(username string, settings map[string]interface{}) { - to_put, _ := json.Marshal(settings) +func UpdateUserSettings(username string, settings map[string]interface{}) error { + to_put, err := json.Marshal(settings) + if err != nil { + return err + } + if vars.SettingsDB == nil { + return errors.New("settings database is unavailable") + } + vars.Mutex.Lock() - vars.SettingsDB.Put([]byte(username), to_put, nil) - vars.Mutex.Unlock() + defer vars.Mutex.Unlock() + return vars.SettingsDB.Put([]byte(username), to_put, nil) } diff --git a/application/backend/app/util/util.go b/application/backend/app/util/util.go index 5065afe..bc995c1 100644 --- a/application/backend/app/util/util.go +++ b/application/backend/app/util/util.go @@ -164,6 +164,19 @@ func GetDB(host string, container string, dbType string) *leveldb.DB { // containersMeta lives beside the containers directory rather than inside it, so // it needs its own accessor. Two copies of this block used to open it with no // lock, which raced and panicked on the flock conflict. +// GetDBIfExists is the read path: it never creates. GetDB opens-or-creates and +// caches forever, so a read route taking a raw container name was a way to make +// the process allocate a LevelDB tree and pin its file descriptors per request. +func GetDBIfExists(host string, container string, dbType string) *leveldb.DB { + if !IsSafeName(host) || !IsSafeName(container) { + return nil + } + if info, err := os.Stat("leveldb/hosts/" + host + "/containers/" + container); err != nil || !info.IsDir() { + return nil + } + return GetDB(host, container, dbType) +} + func GetContainersMetaDB(host string) *leveldb.DB { if !IsSafeName(host) { return nil @@ -297,6 +310,36 @@ func GetDirSize(host string, container string) float64 { return float64(size) / (1024.0 * 1024.0) } +// prunableDBs are the sub-databases retention actually deletes from. Measuring +// anything else makes the quota unreachable: retention would strip these to +// nothing trying to offset data it cannot touch. +var prunableDBs = []string{"logs", "statuses", "statistics"} + +func GetPrunableSize(host string, container string) float64 { + if !IsSafeName(host) || !IsSafeName(container) { + return 0 + } + + var size int64 + for _, dbType := range prunableDBs { + path := "leveldb/hosts/" + host + "/containers/" + container + "/" + dbType + filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info != nil && !info.IsDir() { + size += info.Size() + } + return nil + }) + } + return float64(size) / (1024.0 * 1024.0) +} + +func PrunableDBs() []string { + return append([]string{}, prunableDBs...) +} + func GetUserFromJWT(req http.Request) (string, error) { c, _ := req.Cookie("onlogs-cookie") if c == nil { diff --git a/application/backend/app/vars/state.go b/application/backend/app/vars/state.go index f75540d..b9a184d 100644 --- a/application/backend/app/vars/state.go +++ b/application/backend/app/vars/state.go @@ -1,6 +1,11 @@ package vars -import "sync" +import ( + "errors" + "sort" + "strings" + "sync" +) var stateMutex sync.RWMutex @@ -88,3 +93,26 @@ func TakeQueuedDeletes(host string) []string { delete(ToDelete, host) return queued } + +// CheckDatabases reports the package-level LevelDB handles that failed to open. +// They are opened in variable initialisers, so without this the first +// dereference is a nil-pointer panic on whichever goroutine gets there first. +func CheckDatabases() error { + failures := []string{} + for name, err := range map[string]error{ + "leveldb/favourites": FavsDBErr, + "leveldb/state": StateDBErr, + "leveldb/users": UsersDBErr, + "leveldb/tokens": TokensDBErr, + "leveldb/usersSettings": SettingsDBErr, + } { + if err != nil { + failures = append(failures, name+": "+err.Error()) + } + } + if len(failures) == 0 { + return nil + } + sort.Strings(failures) + return errors.New("unable to open " + strings.Join(failures, "; ")) +} diff --git a/application/backend/main.go b/application/backend/main.go index da10102..82cbe03 100644 --- a/application/backend/main.go +++ b/application/backend/main.go @@ -15,6 +15,7 @@ import ( "github.com/devforth/OnLogs/app/routes" "github.com/devforth/OnLogs/app/streamer" "github.com/devforth/OnLogs/app/util" + "github.com/devforth/OnLogs/app/vars" "github.com/docker/docker/client" "github.com/joho/godotenv" ) @@ -90,6 +91,11 @@ func main() { godotenv.Load(".env") init_config() + if err := vars.CheckDatabases(); err != nil { + fmt.Println("FATAL:", err) + os.Exit(1) + } + if os.Getenv("JWT_SECRET") == "" { fmt.Println("FATAL: JWT_SECRET is empty; refusing to start. Unset it to have one generated, or set a non-empty value.") os.Exit(1) diff --git a/application/frontend/package-lock.json b/application/frontend/package-lock.json index d8507d1..4e12df1 100644 --- a/application/frontend/package-lock.json +++ b/application/frontend/package-lock.json @@ -25,6 +25,7 @@ "@storybook/svelte": "^6.5.12", "@storybook/testing-library": "^0.0.13", "@sveltejs/vite-plugin-svelte": "^1.0.2", + "esbuild": "^0.15.9", "eslint-plugin-svelte3": "^4.0.0", "fs-extra": "^11.1.0", "jsdom": "^24.1.3", diff --git a/application/frontend/package.json b/application/frontend/package.json index 5674561..62f08cb 100644 --- a/application/frontend/package.json +++ b/application/frontend/package.json @@ -25,6 +25,7 @@ "@storybook/svelte": "^6.5.12", "@storybook/testing-library": "^0.0.13", "@sveltejs/vite-plugin-svelte": "^1.0.2", + "esbuild": "^0.15.9", "eslint-plugin-svelte3": "^4.0.0", "fs-extra": "^11.1.0", "jsdom": "^24.1.3", diff --git a/application/frontend/src/Stores/stores.js b/application/frontend/src/Stores/stores.js index f47e58b..5f1a052 100644 --- a/application/frontend/src/Stores/stores.js +++ b/application/frontend/src/Stores/stores.js @@ -52,7 +52,8 @@ export const listScrollIsVisible = writable(false); //confirmation menu export const confirmationObj = writable({ - action: function () {}, + // null means "the default clear-logs flow"; a function replaces it. + action: null, message: "You want to delete host service logs. This data will be lost. This action cannot be undone.", diff --git a/application/frontend/src/Views/Logs/NewLogsV2.svelte b/application/frontend/src/Views/Logs/NewLogsV2.svelte index 6cd09dc..c4172ee 100644 --- a/application/frontend/src/Views/Logs/NewLogsV2.svelte +++ b/application/frontend/src/Views/Logs/NewLogsV2.svelte @@ -141,6 +141,25 @@ // The third element is the full storage key: the only unique row identity. const logKey = (logItem) => logItem?.at(2) ?? logItem?.at(0) ?? ""; + // Every duplication mechanism this codebase has had ends here, at a merge. + // Dropping repeats by key makes a recurrence visible as a missing row rather + // than a doubled one, and lets the {#each} be keyed. + function dedupeLogs(rows) { + const seen = new Set(); + const unique = []; + for (const row of rows) { + const key = logKey(row); + if (key) { + if (seen.has(key)) { + continue; + } + seen.add(key); + } + unique.push(row); + } + return unique; + } + function resetAllLogs() { allLogs = []; newLogs = []; @@ -197,7 +216,7 @@ return; } - allLogs = acc; + allLogs = dedupeLogs(acc); const allLogsCopy = [...allLogs]; newLogs = allLogsCopy.splice(0, limit); visibleLogs = allLogsCopy.splice(0, limit); @@ -294,7 +313,7 @@ } } if (initialService === $lastChosenService && generation === logsLoadGeneration) { - allLogs = [...upperLogs.reverse(), ...viewLogs, ...downLogs]; + allLogs = dedupeLogs([...upperLogs.reverse(), ...viewLogs, ...downLogs]); let allLogsCopy = [...allLogs]; @@ -352,8 +371,8 @@ previousLogs.push(logfromWS); if (allLogs.length === 3 * limit) { - allLogs = [...newLogs, ...visibleLogs, ...previousLogs]; - } else allLogs = [...allLogs, logfromWS]; + allLogs = dedupeLogs([...newLogs, ...visibleLogs, ...previousLogs]); + } else allLogs = dedupeLogs([...allLogs, logfromWS]); if (allLogs.length < 3 * limit) { } } @@ -411,9 +430,12 @@ } } + // Use the shared classifier, not the first raw token: this compared + // against a value only getLogLineStatus ever produces, so warn and + // meta could never match and the tail silently froze. if ( $chosenStatus && - $chosenStatus !== logfromWS[1].split(" ")[0]?.toLowerCase() + $chosenStatus !== getLogLineStatus(logfromWS[1]) ) { return; } else { @@ -600,7 +622,7 @@ newLogs = [...data, ...newLogs]; visibleLogs = [...logsToVisible, ...visibleLogs]; previousLogs = [...logsToPrevious, ...previousLogs].slice(0, limit); - allLogs = [...newLogs, ...visibleLogs, ...previousLogs]; + allLogs = dedupeLogs([...newLogs, ...visibleLogs, ...previousLogs]); if (data.length === limit && !doNotScroll) { setTimeout(() => { @@ -675,7 +697,7 @@ newLogs = [...total_logs, ...newLogs]; visibleLogs = [...logsToVisible, ...visibleLogs]; previousLogs = [...logsToPrevious, ...previousLogs]; - allLogs = [...newLogs, ...visibleLogs, ...previousLogs]; + allLogs = dedupeLogs([...newLogs, ...visibleLogs, ...previousLogs]); } fetchedData = total_logs; } @@ -723,7 +745,7 @@ newLogs = [...newLogs, ...logsToNew]; visibleLogs = [...visibleLogs, ...logsToVisible]; previousLogs = [...previousLogs, ...total_logs]; - allLogs = [...newLogs, ...visibleLogs, ...previousLogs]; + allLogs = dedupeLogs([...newLogs, ...visibleLogs, ...previousLogs]); if (total_logs.length === limit) { scrollToNewLogsEnd(".newLogsEnd", true); } @@ -970,7 +992,7 @@ >
- {#each allLogs as logItem, i} + {#each allLogs as logItem, i (logKey(logItem) || i)} {#if transformLogStringForTimeBudget(logItem, $store.UTCtime) !== transformLogStringForTimeBudget(allLogs[i - 1], $store.UTCtime) && i - 1 >= 0}
@@ -1076,7 +1098,7 @@ await exitSharedLinkFocusMode(true); scrollFromButton = true; autoscroll = true; - scrollDirection === "up"; + scrollDirection = "up"; // logsFromWS.length && (await getFullLogsSet()); setTimeout(() => { diff --git a/application/frontend/src/Views/Logs/NewLogsV2.test.mjs b/application/frontend/src/Views/Logs/NewLogsV2.test.mjs index 0d9713d..ddeab71 100644 --- a/application/frontend/src/Views/Logs/NewLogsV2.test.mjs +++ b/application/frontend/src/Views/Logs/NewLogsV2.test.mjs @@ -73,6 +73,28 @@ function stubFailingFetch(counter) { }; } +// A backend whose cursor does not advance re-serves the same rows on every page — +// the exact shape of the cursor defect. One load then accumulates them all. +function stubOverlappingFetch(counter) { + const rows = [ + ["2026-02-10T12:44:03.560000000Z", "row-a", "2026-02-10T12:44:03.560000000Z +1-1"], + ["2026-02-10T12:44:03.561000000Z", "row-b", "2026-02-10T12:44:03.561000000Z +1-2"], + ]; + return async (url) => { + const target = String(url); + if (target.includes("getLogs?")) { + counter.getLogs += 1; + return jsonResponse({ + logs: rows.map((r) => [...r]), + // The cursor never advances, so the same page comes back each round. + last_processed_key: rows.at(-1)[2], + is_end: false, + }); + } + return jsonResponse({ logs: [], last_processed_key: "", is_end: true }); + }; +} + function renderedRowCount(target) { return target.querySelectorAll(".chosenString").length; } @@ -202,6 +224,69 @@ async function run() { "a destroyed view left the shared isFeatching store set, which blocks the live one" ); + // --- a re-served row must not reach the screen twice --- + const overlapCounter = { getLogs: 0 }; + const overlap = await mount( + bundle, + stubOverlappingFetch(overlapCounter), + overlapCounter + ); + const overlapMessages = [...overlap.target.querySelectorAll(".message p")] + .map((el) => el.textContent.trim()) + .filter((t) => t.startsWith("row-")); + + console.log(` overlapping pages rendered: ${JSON.stringify(overlapMessages)}`); + assert.equal( + new Set(overlapMessages).size, + overlapMessages.length, + `a re-served row reached the screen twice: ${JSON.stringify(overlapMessages)}` + ); + overlap.component.$destroy(); + + // --- a status filter must not silently freeze the live tail --- + const wsCounter = { getLogs: 0 }; + const { window: wsWindow, sockets: wsSockets } = installDom({ + fetchImpl: stubFetch(wsCounter), + }); + bundle.lastChosenHost.set("testhost"); + bundle.lastChosenService.set("testservice"); + bundle.isPending.set(false); + const filtered = new bundle.NewLogsV2({ target: wsWindow.document.body }); + await settle(); + + bundle.chosenStatus.set("warn"); + await settle(5); + + const socket = wsSockets.at(-1); + assert.ok(socket, "no websocket was opened"); + + // Nothing sets endOffLogsIntersect under jsdom, so a delivered line lands in + // the buffer and shows on the scroll-to-bottom badge. + const bufferedCount = () => { + const badge = wsWindow.document.querySelector(".buttonToBottomNumber p"); + return badge ? Number(badge.textContent.trim()) : 0; + }; + + // A line the shared classifier calls "warn". The old check compared the filter + // against the first raw token, so this could never match. + socket.onmessage({ + data: JSON.stringify([ + "2026-02-10T12:44:09.000000000Z", + "2026-02-10 WARNING disk almost full", + "2026-02-10T12:44:09.000000000Z +9-9", + ]), + }); + await settle(5); + + const delivered = bufferedCount(); + console.log(` live warn line with a "warn" filter delivered: ${delivered > 0}`); + assert.ok( + delivered > 0, + "a live line the classifier calls warn was dropped while the warn filter was active" + ); + bundle.chosenStatus.set(""); + filtered.$destroy(); + console.log("NewLogsV2 duplication tests passed"); process.exit(0); } diff --git a/application/frontend/src/Views/Main/Main.svelte b/application/frontend/src/Views/Main/Main.svelte index 3d3bb21..b0f1ffe 100644 --- a/application/frontend/src/Views/Main/Main.svelte +++ b/application/frontend/src/Views/Main/Main.svelte @@ -88,10 +88,10 @@ navigate(`${changeKey}/login`, { replace: true }); } - userMenuOpen.subscribe((v) => { + const unsubscribeUserMenu = userMenuOpen.subscribe((v) => { userMenuState = v; }); - addUserModalOpen.subscribe((v) => { + const unsubscribeAddUserModal = addUserModalOpen.subscribe((v) => { addUserModOpen = v; }); @@ -140,6 +140,8 @@ onDestroy(() => { clearInterval(intervalId); + unsubscribeUserMenu(); + unsubscribeAddUserModal(); }); // $: { diff --git a/application/frontend/src/lib/CheckBox/Checkbox.svelte b/application/frontend/src/lib/CheckBox/Checkbox.svelte index 2bb7211..8600af2 100644 --- a/application/frontend/src/lib/CheckBox/Checkbox.svelte +++ b/application/frontend/src/lib/CheckBox/Checkbox.svelte @@ -10,7 +10,7 @@ unsubscribe = store.subscribe((v) => (initialValue = v[storeValue])); active = initialValue; }); - onDestroy(unsubscribe); + onDestroy(() => unsubscribe()); function handleClick() { active = !active; diff --git a/application/frontend/src/lib/ClientPanel/ClientPanel.svelte b/application/frontend/src/lib/ClientPanel/ClientPanel.svelte index 2949291..65cd913 100644 --- a/application/frontend/src/lib/ClientPanel/ClientPanel.svelte +++ b/application/frontend/src/lib/ClientPanel/ClientPanel.svelte @@ -14,7 +14,8 @@ let localTheme = ""; let api = new fetchApi(); - const showUserMenu = window.DISABLE_AUTH ?? true; + // The Users menu belongs on authenticated deployments, not on DISABLE_AUTH ones. + const showUserMenu = !(window.DISABLE_AUTH ?? false); //store management function toggleUserMenu() { diff --git a/application/frontend/src/lib/ConfirmationMenu/ConfirmationMenu.svelte b/application/frontend/src/lib/ConfirmationMenu/ConfirmationMenu.svelte index 57c9aea..d8ed1c3 100644 --- a/application/frontend/src/lib/ConfirmationMenu/ConfirmationMenu.svelte +++ b/application/frontend/src/lib/ConfirmationMenu/ConfirmationMenu.svelte @@ -62,7 +62,19 @@ } $: { - changeMessage($store.deleteFromDocker); + // Only the clear-logs flow owns the message; a caller-supplied action + // brings its own. + if (!$confirmationObj.action) { + changeMessage($store.deleteFromDocker); + } + } + + async function confirm() { + if (typeof $confirmationObj.action === "function") { + await $confirmationObj.action(); + return; + } + await deletelogs(); } @@ -121,7 +133,7 @@ title={"Delete"} highlighted={true} CB={() => { - deletelogs(); + confirm(); }} />
- + diff --git a/application/frontend/src/Views/Logs/LogStringHeader.svelte b/application/frontend/src/Views/Logs/LogStringHeader.svelte index 1c03d81..6b44cbe 100644 --- a/application/frontend/src/Views/Logs/LogStringHeader.svelte +++ b/application/frontend/src/Views/Logs/LogStringHeader.svelte @@ -4,4 +4,4 @@ import { chosenLogsString } from "../../Stores/stores.js"; -
+
diff --git a/application/frontend/src/Views/Logs/LogsViewHeder/LogsViewHeder.svelte b/application/frontend/src/Views/Logs/LogsViewHeder/LogsViewHeder.svelte index d2d8fa1..c0e0d60 100644 --- a/application/frontend/src/Views/Logs/LogsViewHeder/LogsViewHeder.svelte +++ b/application/frontend/src/Views/Logs/LogsViewHeder/LogsViewHeder.svelte @@ -1,17 +1,23 @@ @@ -49,7 +57,7 @@
diff --git a/application/frontend/src/lib/ButtonToBottom/ButtonToBottom.svelte b/application/frontend/src/lib/ButtonToBottom/ButtonToBottom.svelte index eda3843..a79dbb0 100644 --- a/application/frontend/src/lib/ButtonToBottom/ButtonToBottom.svelte +++ b/application/frontend/src/lib/ButtonToBottom/ButtonToBottom.svelte @@ -1,18 +1,24 @@
{ + onclick={async () => { await callBack(); }} >
- +
{#if number}

{number}

diff --git a/application/frontend/src/lib/ChartMenu/MainChartMenu.svelte b/application/frontend/src/lib/ChartMenu/MainChartMenu.svelte index 32b899c..c228442 100644 --- a/application/frontend/src/lib/ChartMenu/MainChartMenu.svelte +++ b/application/frontend/src/lib/ChartMenu/MainChartMenu.svelte @@ -32,7 +32,7 @@ {#each headerOptions as option}
  • { + onclick={() => { lastStatisticPeriod.set(option); }} > diff --git a/application/frontend/src/lib/CheckBox/Checkbox.svelte b/application/frontend/src/lib/CheckBox/Checkbox.svelte index 8600af2..60b8d3c 100644 --- a/application/frontend/src/lib/CheckBox/Checkbox.svelte +++ b/application/frontend/src/lib/CheckBox/Checkbox.svelte @@ -1,9 +1,15 @@
    @@ -12,7 +18,7 @@ {#each listData as listEl, index}
  • { + onclick={() => { isRowClickable && storeProp?.set && storeProp.set(listEl.name); initialActive = null; listEl.callBack(); @@ -24,13 +30,13 @@

  • - +
    +>
    {/each} diff --git a/application/frontend/src/lib/CommonList/CommonList.test.mjs b/application/frontend/src/lib/CommonList/CommonList.test.mjs new file mode 100644 index 0000000..dd50f3b --- /dev/null +++ b/application/frontend/src/lib/CommonList/CommonList.test.mjs @@ -0,0 +1,37 @@ +// listData defaults to [], and the initial-active lookup indexed [0] with no +// guard, so rendering the component with an empty list threw. +import assert from "node:assert/strict"; +import { bundleComponent, installDom, importBundle, settle } from "../../../test/harness.mjs"; + +async function run() { + const outfile = await bundleComponent("test/entry.js"); + const { window } = installDom({}); + const bundle = await importBundle(outfile); + const target = window.document.body; + + const empty = bundle.mount(bundle.CommonList, { target, props: { listData: [] } }); + await settle(2); + console.log(` empty list rendered ${target.querySelectorAll("li").length} row(s)`); + assert.equal(target.querySelectorAll("li").length, 0); + bundle.unmount(empty); + + const filled = bundle.mount(bundle.CommonList, { + target, + props: { listData: [{ name: "alpha", ico: "X", callBack: () => {} }] }, + }); + await settle(2); + assert.ok(target.textContent.includes("alpha"), "a populated list should still render"); + assert.ok( + target.querySelector(".highlightedOverlay.active"), + "the first row should start active" + ); + bundle.unmount(filled); + + console.log("CommonList empty-list tests passed"); + process.exit(0); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/application/frontend/src/lib/ConfirmationMenu/ConfirmationMenu.svelte b/application/frontend/src/lib/ConfirmationMenu/ConfirmationMenu.svelte index d8ed1c3..b122e0b 100644 --- a/application/frontend/src/lib/ConfirmationMenu/ConfirmationMenu.svelte +++ b/application/frontend/src/lib/ConfirmationMenu/ConfirmationMenu.svelte @@ -1,4 +1,5 @@
    - + {@render children?.()}
    diff --git a/application/frontend/src/lib/Container/ContainerView.svelte b/application/frontend/src/lib/Container/ContainerView.svelte index d6c9eda..ea225d8 100644 --- a/application/frontend/src/lib/Container/ContainerView.svelte +++ b/application/frontend/src/lib/Container/ContainerView.svelte @@ -1,6 +1,12 @@ diff --git a/application/frontend/src/lib/DropDown/DropDown.svelte b/application/frontend/src/lib/DropDown/DropDown.svelte index 4d49950..00f62a1 100644 --- a/application/frontend/src/lib/DropDown/DropDown.svelte +++ b/application/frontend/src/lib/DropDown/DropDown.svelte @@ -4,7 +4,7 @@ diff --git a/application/frontend/src/lib/DropDown/DropDownAddHost.svelte b/application/frontend/src/lib/DropDown/DropDownAddHost.svelte index 3f954f3..bde2221 100644 --- a/application/frontend/src/lib/DropDown/DropDownAddHost.svelte +++ b/application/frontend/src/lib/DropDown/DropDownAddHost.svelte @@ -8,7 +8,7 @@ diff --git a/application/frontend/src/lib/DropDown/dropDownRow.svelte b/application/frontend/src/lib/DropDown/dropDownRow.svelte index 94e1e2f..0556737 100644 --- a/application/frontend/src/lib/DropDown/dropDownRow.svelte +++ b/application/frontend/src/lib/DropDown/dropDownRow.svelte @@ -1,14 +1,28 @@
    diff --git a/application/frontend/src/lib/ListWithChoise/ListWithChoise.svelte b/application/frontend/src/lib/ListWithChoise/ListWithChoise.svelte index dfafdb9..474b01f 100644 --- a/application/frontend/src/lib/ListWithChoise/ListWithChoise.svelte +++ b/application/frontend/src/lib/ListWithChoise/ListWithChoise.svelte @@ -1,21 +1,11 @@
    @@ -171,14 +171,14 @@
  • { + onclick={({ target }) => { if (target.id !== headerButton) { toggleSublistVisible(index); } }} >
    - +

    {listEl.host} @@ -186,11 +186,11 @@ {#if headerButton}

    { + onclick={() => { console.log("clicable"); }} > - +
    {/if}
    { + onclick={({ target }) => { if (!target.id.includes("heart")) { choseSublistEl(listEl.host, service.serviceName); lastChosenHost.set(listEl.host); @@ -228,7 +228,7 @@
    { + onclick={() => { navigate( `${changeKey}/servicesettings/${listEl.host.trim()}/${service.serviceName.trim()}`, { replace: true } @@ -237,12 +237,12 @@ chosenElSettings = `${listEl.host.trim()}-${service.serviceName.trim()}`; }} > - +
    { + onclick={() => { favoriteToggle(listEl.host, service.serviceName); }} > @@ -251,7 +251,7 @@ class="log {service.isFavorite ? 'log-Heart' : 'log-EmptyHeart'}" - /> +>
    {/if} @@ -263,7 +263,7 @@ ? "active" : `` }`} - /> +>
  • {/if}{/each} @@ -275,18 +275,18 @@ ) ? '' : 'visuallyHidden'}" - on:click={() => { + onclick={() => { toggleArchivedVisible(index); initialVisitcounter = 1; }} > - +

    stopped services

    +>
      { + onclick={({ target }) => { if (!target.id.includes("heart")) { choseSublistEl(listEl.host, service.serviceName); lastChosenHost.set(listEl.host); @@ -324,7 +324,7 @@
      { + onclick={() => { navigate( `${changeKey}/servicesettings/${listEl.host.trim()}/${service.serviceName.trim()}`, { replace: true } @@ -333,12 +333,12 @@ chosenElSettings = `${listEl.host.trim()}-${service.serviceName.trim()}`; }} > - +
      { + onclick={() => { favoriteToggle(listEl.host, service.serviceName); }} > @@ -347,7 +347,7 @@ class="log {service.isFavorite ? 'log-Heart' : 'log-EmptyHeart'}" - /> +>
      {/if} @@ -359,7 +359,7 @@ ? "active" : `` }`} - /> +>
    {/if}{/each}
    diff --git a/application/frontend/src/lib/LogsSize/LogsSize.svelte b/application/frontend/src/lib/LogsSize/LogsSize.svelte index e214ccf..9f0d5b8 100644 --- a/application/frontend/src/lib/LogsSize/LogsSize.svelte +++ b/application/frontend/src/lib/LogsSize/LogsSize.svelte @@ -1,8 +1,7 @@
    - + {#if !isAllLogs}
    +
    {#if !parsedStr}

    {@html messageHtml}

    {:else if $store.transformJson}

    {@html toAnsiHtml(parsedStr.startText)}

    @@ -61,5 +75,5 @@ {:else}

    {@html messageHtml}

    {/if} - - +
    +
    diff --git a/application/frontend/src/lib/Modal/Modal.svelte b/application/frontend/src/lib/Modal/Modal.svelte index 91d006b..46cfac4 100644 --- a/application/frontend/src/lib/Modal/Modal.svelte +++ b/application/frontend/src/lib/Modal/Modal.svelte @@ -1,9 +1,21 @@
    -
    +
    diff --git a/application/frontend/src/lib/SecretModal/DockerComposeSnippet.svelte b/application/frontend/src/lib/SecretModal/DockerComposeSnippet.svelte index 6e08eae..14064cf 100644 --- a/application/frontend/src/lib/SecretModal/DockerComposeSnippet.svelte +++ b/application/frontend/src/lib/SecretModal/DockerComposeSnippet.svelte @@ -1,6 +1,12 @@
    example_onlogs:
    diff --git a/application/frontend/src/lib/SecretModal/DockerSnippet.svelte b/application/frontend/src/lib/SecretModal/DockerSnippet.svelte
    index e315cd8..39444cb 100644
    --- a/application/frontend/src/lib/SecretModal/DockerSnippet.svelte
    +++ b/application/frontend/src/lib/SecretModal/DockerSnippet.svelte
    @@ -1,6 +1,12 @@
     
     
     
    diff --git a/application/frontend/src/lib/SecretModal/SecretModal.svelte b/application/frontend/src/lib/SecretModal/SecretModal.svelte
    index b57d6ca..15facfe 100644
    --- a/application/frontend/src/lib/SecretModal/SecretModal.svelte
    +++ b/application/frontend/src/lib/SecretModal/SecretModal.svelte
    @@ -15,10 +15,10 @@
       import DockerSnippet from "./DockerSnippet.svelte";
       import DockerComposeSnippet from "./DockerComposeSnippet.svelte";
     
    -  let token = "";
    +  let token = $state("");
       let origin = `${location.origin}${changeKey}`;
       const api = new FetchApi();
    -  let secretError = "";
    +  let secretError = $state("");
       async function getSecret() {
         try {
           const data = await api.getSecret();
    @@ -44,7 +44,7 @@
     
    { + onclick_outside={() => { snipetModalIsVisible.set(false); }} > @@ -54,7 +54,7 @@ class={`labelItem clickable ${ $currentSnippedOption === "Docker" ? "active" : "" }`} - on:click={() => { + onclick={() => { choseSnippetOption("Docker"); }} > @@ -64,7 +64,7 @@ class={`labelItem clickable ${ $currentSnippedOption === "DockerCompose" ? "active" : "" }`} - on:click={() => { + onclick={() => { choseSnippetOption("DockerCompose"); }} > @@ -116,9 +116,9 @@ />
    -
    +
    { + onkeydown={(e) => { handleKeydown(e, "Escape", () => { snipetModalIsVisible.set(false); }); diff --git a/application/frontend/src/lib/Stats/Stats.svelte b/application/frontend/src/lib/Stats/Stats.svelte index b577d1e..e4c94b9 100644 --- a/application/frontend/src/lib/Stats/Stats.svelte +++ b/application/frontend/src/lib/Stats/Stats.svelte @@ -8,7 +8,7 @@ } from "../../Stores/stores.js"; import fetchApi from "../../utils/fetch"; - let data = {}; + let data = $state({}); const api = new fetchApi(); let intervalId; @@ -35,24 +35,23 @@ clearInterval(intervalId); }); - $: { - (async () => { - if ($lastChosenHost && $lastChosenService) { - data = await api.getStats({ - period: $lastStatsPeriod, - service: $lastChosenService, - host: $lastChosenHost, - }); - } - })(); - } + $effect(() => { + if ($lastChosenHost && $lastChosenService) { + const period = $lastStatsPeriod; + const service = $lastChosenService; + const host = $lastChosenHost; + api.getStats({ period, service, host }).then((result) => { + data = result; + }); + } + });
    { + onclick={() => { // navigate( // `${changeKey}/stats/${$lastChosenHost}/${$lastChosenService}`, // { @@ -61,11 +60,11 @@ // ); }} title="Counter updates every 1 min since OnLogs started. So, it may cause some asynchrony." - /> +>
    { + onclick={() => { setPeriod(2); }} > @@ -73,7 +72,7 @@
    { + onclick={() => { setPeriod(48); }} > @@ -81,7 +80,7 @@
    { + onclick={() => { setPeriod(336); }} > @@ -89,7 +88,7 @@
    { + onclick={() => { setPeriod(1344); }} > @@ -110,7 +109,7 @@ } }) as [key, name]}
  • { + onclick={async () => { if ($chosenStatus !== key) { chosenStatus.set(key); } else { diff --git a/application/frontend/src/lib/StreamInfo/StreamInfo.svelte b/application/frontend/src/lib/StreamInfo/StreamInfo.svelte index be85a96..9fce014 100644 --- a/application/frontend/src/lib/StreamInfo/StreamInfo.svelte +++ b/application/frontend/src/lib/StreamInfo/StreamInfo.svelte @@ -4,9 +4,13 @@ import { lastLogTime, WSisMuted, manuallyUnmuted } from "../../Stores/stores"; import { getTimeDifference } from "../../utils/functions"; let lastLogsCheckerInterval = null; - let componentLastLogTime = [] || ""; + let componentLastLogTime = $state([] || ""); - $: componentLastLogTime = getTimeDifference($lastLogTime); + // Not $derived: the interval below also refreshes this so the elapsed time + // keeps counting up while $lastLogTime stays put. + $effect.pre(() => { + componentLastLogTime = getTimeDifference($lastLogTime); + }); let LAST_LOG_INTWRVAL = 5000; function checkLastLogs() { @@ -29,7 +33,7 @@ { + onclick={() => { if ($WSisMuted) { WSisMuted.set(false); manuallyUnmuted.set(true); @@ -38,7 +42,7 @@ manuallyUnmuted.set(false); } }} - /> +>

    Last log line:

    {#if Array.isArray(componentLastLogTime)} @@ -60,6 +64,6 @@ {/if} {/if}
    - +
  • diff --git a/application/frontend/src/lib/Toast/Toast.svelte b/application/frontend/src/lib/Toast/Toast.svelte index a4239c1..1917990 100644 --- a/application/frontend/src/lib/Toast/Toast.svelte +++ b/application/frontend/src/lib/Toast/Toast.svelte @@ -11,7 +11,7 @@ import { fly } from "svelte/transition"; // Reactive: the component is reused across toasts, so a non-reactive // destructure shows the previous message with the new icon. - $: ({ tittle, message, status, additionButton } = $toast); + let { tittle, message, status, additionButton } = $derived($toast); onDestroy(() => { if ($toastTimeoutId) { @@ -24,7 +24,7 @@ transition:fly={{ y: -200, duration: 200 }} class="toastContainer {status}" > -
    +

    {tittle}

    {message}

    @@ -57,7 +57,7 @@
    { + onkeydown={(e) => { handleKeydown(e, "Escape", () => { toastIsVisible.set(false); if (toastTimeoutId) { diff --git a/application/frontend/src/lib/UserMenu/UserManageForm.svelte b/application/frontend/src/lib/UserMenu/UserManageForm.svelte index 80bf475..e7a4504 100644 --- a/application/frontend/src/lib/UserMenu/UserManageForm.svelte +++ b/application/frontend/src/lib/UserMenu/UserManageForm.svelte @@ -1,9 +1,7 @@
    @@ -42,7 +40,7 @@
    diff --git a/application/frontend/src/lib/UserMenu/UserMenu.svelte b/application/frontend/src/lib/UserMenu/UserMenu.svelte index f7e0a92..5b690b5 100644 --- a/application/frontend/src/lib/UserMenu/UserMenu.svelte +++ b/application/frontend/src/lib/UserMenu/UserMenu.svelte @@ -12,16 +12,21 @@ import Modal from "../Modal/Modal.svelte"; import Input from "../Input/Input.svelte"; - let usersList = []; + let usersList = $state([]); - let chosenUserLogin = ""; - let deleteModalIsOpen = false; - let editModalIsOpen = false; - let userPasswordValue = ""; + let chosenUserLogin = $state(""); + let deleteModalIsOpen = $state(false); + let editModalIsOpen = $state(false); + let userPasswordValue = $state(""); - export let userForAdding = ""; + /** + * @typedef {Object} Props + * @property {string} [userForAdding] + */ + + /** @type {Props} */ + let { userForAdding = "" } = $props(); - $: userForAdding && addUser(userForAdding); function addUser(u) { if (u) { @@ -133,6 +138,9 @@ onDestroy(() => { delUnsubscribe(), editUnsubscribe(); }); + $effect(() => { + userForAdding && addUser(userForAdding); + });
    @@ -141,33 +149,35 @@ {#if usersList}

    Users:

    -
    - +
    +
    - + + + {#each usersList as user, i} {}, } = $props(); - let activeStatus = ""; let parsedStr = $derived(tryToParseLogString(message)); let messageHtml = $derived(toAnsiHtml(message)); diff --git a/application/frontend/src/lib/ProgressBar/ProgressBar.scss b/application/frontend/src/lib/ProgressBar/ProgressBar.scss deleted file mode 100644 index af760f7..0000000 --- a/application/frontend/src/lib/ProgressBar/ProgressBar.scss +++ /dev/null @@ -1,14 +0,0 @@ -.progressBarContainer { - width: 100%; - height: 12px; - background-color: $background-color; - border: 2px; - border-radius: 4px; - overflow: hidden; - position: relative; - - .progressBarValue { - height: 12px; - background-color: $active-color; - } -} diff --git a/application/frontend/src/lib/ProgressBar/ProgressBar.svelte b/application/frontend/src/lib/ProgressBar/ProgressBar.svelte deleted file mode 100644 index 49a9e43..0000000 --- a/application/frontend/src/lib/ProgressBar/ProgressBar.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - -
    -
    -
    diff --git a/application/frontend/src/lib/StreamInfo/StreamInfo.svelte b/application/frontend/src/lib/StreamInfo/StreamInfo.svelte index 9fce014..f814b7b 100644 --- a/application/frontend/src/lib/StreamInfo/StreamInfo.svelte +++ b/application/frontend/src/lib/StreamInfo/StreamInfo.svelte @@ -4,7 +4,7 @@ import { lastLogTime, WSisMuted, manuallyUnmuted } from "../../Stores/stores"; import { getTimeDifference } from "../../utils/functions"; let lastLogsCheckerInterval = null; - let componentLastLogTime = $state([] || ""); + let componentLastLogTime = $state([]); // Not $derived: the interval below also refreshes this so the elapsed time // keeps counting up while $lastLogTime stays put. diff --git a/application/frontend/src/lib/Toast/Toast.svelte b/application/frontend/src/lib/Toast/Toast.svelte index 1917990..a376d5d 100644 --- a/application/frontend/src/lib/Toast/Toast.svelte +++ b/application/frontend/src/lib/Toast/Toast.svelte @@ -4,7 +4,6 @@ toast, toastTimeoutId, } from "../../Stores/stores.js"; - import ProgressBar from "../ProgressBar/ProgressBar.svelte"; import Button from "../Button/Button.svelte"; import { handleKeydown } from "../../utils/functions.js"; import { onDestroy, onMount } from "svelte"; @@ -28,7 +27,6 @@

    {tittle}

    {message}

    -
    {#if additionButton?.isVisible} diff --git a/application/frontend/src/main.scss b/application/frontend/src/main.scss index 2594fa9..4aeaa33 100644 --- a/application/frontend/src/main.scss +++ b/application/frontend/src/main.scss @@ -24,7 +24,6 @@ @import "./Views/ServiceSettings/ServiceSettings.scss"; @import "./lib/Stats/Stats.scss"; @import "./lib/ChartMenu/ChartMenu.scss"; -@import "./lib/ProgressBar/ProgressBar.scss"; @import "./lib/StreamInfo/StreamInfo.scss"; body { font-family: "Ubuntu", sans-serif; diff --git a/application/frontend/src/utils/_variables.scss b/application/frontend/src/utils/_variables.scss index a5f548e..3da05d6 100644 --- a/application/frontend/src/utils/_variables.scss +++ b/application/frontend/src/utils/_variables.scss @@ -16,7 +16,6 @@ $inpun-color-dark: #99a9b931; $text-onactive-color-dark: #e4e7eb; $text-dark-color-dark: #cbd2d9; $text-placeholder-color-dark: #e4e7eb; -$lines-color-dark: #cbd2d9; $background-color-dark: #121212; $btn-primary-color-dark: #c244db; $lines-color-dark: #cbd2d938; From cd7f010b88e499cfbe964606c581cc767d9184ab Mon Sep 17 00:00:00 2001 From: Roman Malenko Date: Sat, 8 Aug 2026 17:27:11 +0300 Subject: [PATCH 23/31] test(frontend): run each test file through a shared main helper --- application/frontend/src/App.routing.test.mjs | 9 +-- .../Logs/LogsViewHeder/LogsViewHeder.test.mjs | 9 +-- .../Views/Logs/NewLogsV2.pagination.test.mjs | 9 +-- .../Logs/NewLogsV2.requeststorm.test.mjs | 9 +-- .../Views/Logs/NewLogsV2.sharecopy.test.mjs | 9 +-- .../Views/Logs/NewLogsV2.sharedlink.test.mjs | 9 +-- .../src/Views/Logs/NewLogsV2.test.mjs | 9 +-- .../src/lib/CommonList/CommonList.test.mjs | 10 +--- .../src/lib/GroupModal/GroupModal.test.mjs | 9 +-- .../ListWithChoise/ListWithChoise.test.mjs | 57 ++++++++++++++++--- application/frontend/test/harness.mjs | 11 ++++ 11 files changed, 79 insertions(+), 71 deletions(-) diff --git a/application/frontend/src/App.routing.test.mjs b/application/frontend/src/App.routing.test.mjs index 62662e3..0ac9c4c 100644 --- a/application/frontend/src/App.routing.test.mjs +++ b/application/frontend/src/App.routing.test.mjs @@ -8,6 +8,7 @@ import { jsonResponse, settle, importBundle, + main, } from "../test/harness.mjs"; async function stubFetch(url) { @@ -24,7 +25,7 @@ async function stubFetch(url) { const NOT_FOUND = "404 This is not the web page"; -async function run() { +main(async () => { // changeKey.js reads `location` at module scope, so the DOM has to exist // before the bundle is imported. const outfile = await bundleComponent("test/entry.js"); @@ -63,10 +64,4 @@ async function run() { bundle.unmount(unmatched.app); console.log("App routing tests passed"); - process.exit(0); -} - -run().catch((err) => { - console.error(err); - process.exit(1); }); diff --git a/application/frontend/src/Views/Logs/LogsViewHeder/LogsViewHeder.test.mjs b/application/frontend/src/Views/Logs/LogsViewHeder/LogsViewHeder.test.mjs index c7478db..ebd4b95 100644 --- a/application/frontend/src/Views/Logs/LogsViewHeder/LogsViewHeder.test.mjs +++ b/application/frontend/src/Views/Logs/LogsViewHeder/LogsViewHeder.test.mjs @@ -4,6 +4,7 @@ import { installDom, settle, importBundle, + main, } from "../../../../test/harness.mjs"; const DEBOUNCE_MS = 750; @@ -13,7 +14,7 @@ function type(window, input, value) { input.dispatchEvent(new window.Event("input", { bubbles: true })); } -async function run() { +main(async () => { const bundlePath = await bundleComponent("test/entry.js"); const { window } = installDom({}); const bundle = await importBundle(bundlePath); @@ -65,10 +66,4 @@ async function run() { bundle.unmount(component); console.log("LogsViewHeder debounce tests passed"); - process.exit(0); -} - -run().catch((err) => { - console.error(err); - process.exit(1); }); diff --git a/application/frontend/src/Views/Logs/NewLogsV2.pagination.test.mjs b/application/frontend/src/Views/Logs/NewLogsV2.pagination.test.mjs index ffafd76..68402bc 100644 --- a/application/frontend/src/Views/Logs/NewLogsV2.pagination.test.mjs +++ b/application/frontend/src/Views/Logs/NewLogsV2.pagination.test.mjs @@ -7,6 +7,7 @@ import { jsonResponse, settle, importBundle, + main, } from "../../../test/harness.mjs"; const LIMIT = 60; @@ -68,7 +69,7 @@ function assertInvariants(target, label) { return messages; } -async function run() { +main(async () => { const counter = { getLogs: 0 }; const { window } = installDom({ fetchImpl: stubFetch(counter) }); const bundle = await importBundle(await bundleComponent("test/entry.js")); @@ -119,10 +120,4 @@ async function run() { bundle.unmount(component); console.log("NewLogsV2 pagination tests passed"); - process.exit(0); -} - -run().catch((err) => { - console.error(err); - process.exit(1); }); diff --git a/application/frontend/src/Views/Logs/NewLogsV2.requeststorm.test.mjs b/application/frontend/src/Views/Logs/NewLogsV2.requeststorm.test.mjs index d36a6d0..dac7737 100644 --- a/application/frontend/src/Views/Logs/NewLogsV2.requeststorm.test.mjs +++ b/application/frontend/src/Views/Logs/NewLogsV2.requeststorm.test.mjs @@ -11,6 +11,7 @@ import { jsonResponse, settle, importBundle, + main, } from "../../../test/harness.mjs"; const LIMIT = 60; @@ -91,7 +92,7 @@ async function idleRequests(endpoint, rounds) { return counter[endpoint] - before; } -async function run() { +main(async () => { const { window } = installDom({ fetchImpl }); const bundle = await importBundle(await bundleComponent("test/entry.js")); @@ -153,10 +154,4 @@ async function run() { ); console.log("NewLogsV2 request-storm tests passed"); - process.exit(0); -} - -run().catch((err) => { - console.error(String(err.message || err)); - process.exit(1); }); diff --git a/application/frontend/src/Views/Logs/NewLogsV2.sharecopy.test.mjs b/application/frontend/src/Views/Logs/NewLogsV2.sharecopy.test.mjs index 57d9542..74a67aa 100644 --- a/application/frontend/src/Views/Logs/NewLogsV2.sharecopy.test.mjs +++ b/application/frontend/src/Views/Logs/NewLogsV2.sharecopy.test.mjs @@ -5,6 +5,7 @@ import { jsonResponse, settle, importBundle, + main, } from "../../../test/harness.mjs"; const ROWS = Array.from({ length: 90 }, (_, i) => { @@ -44,7 +45,7 @@ function renderedMessages(target) { .filter((text) => text.startsWith("line-")); } -async function run() { +main(async () => { const counter = { getLogs: 0, getPrevLogs: 0, withPrev: 0 }; const { window } = installDom({ fetchImpl: stubFetch(counter) }); @@ -111,10 +112,4 @@ async function run() { bundle.unmount(component); console.log("share-link copy tests passed"); - process.exit(0); -} - -run().catch((err) => { - console.error(err); - process.exit(1); }); diff --git a/application/frontend/src/Views/Logs/NewLogsV2.sharedlink.test.mjs b/application/frontend/src/Views/Logs/NewLogsV2.sharedlink.test.mjs index 9708213..c6e0ba0 100644 --- a/application/frontend/src/Views/Logs/NewLogsV2.sharedlink.test.mjs +++ b/application/frontend/src/Views/Logs/NewLogsV2.sharedlink.test.mjs @@ -5,6 +5,7 @@ import { jsonResponse, settle, importBundle, + main, } from "../../../test/harness.mjs"; const LINKED = "2026-02-10T09:00:05.000000000Z"; @@ -72,7 +73,7 @@ function openDeepLink(latency) { return { component, target, counter }; } -async function run() { +main(async () => { // The deep link must win regardless of how the three concurrent loads // interleave, so exercise a range of response latencies. for (const latency of [0, 5, 20]) { @@ -101,10 +102,4 @@ async function run() { } console.log("shared link deep-link window tests passed"); - process.exit(0); -} - -run().catch((err) => { - console.error(err); - process.exit(1); }); diff --git a/application/frontend/src/Views/Logs/NewLogsV2.test.mjs b/application/frontend/src/Views/Logs/NewLogsV2.test.mjs index f574f7f..0a3b69e 100644 --- a/application/frontend/src/Views/Logs/NewLogsV2.test.mjs +++ b/application/frontend/src/Views/Logs/NewLogsV2.test.mjs @@ -5,6 +5,7 @@ import { jsonResponse, settle, importBundle, + main, } from "../../../test/harness.mjs"; const PAGE = [ @@ -109,7 +110,7 @@ async function mountView(bundle, fetchImpl, counter) { return { component, target, counter }; } -async function run() { +main(async () => { const bundlePath = await bundleComponent("test/entry.js"); const counter = { getLogs: 0 }; @@ -273,10 +274,4 @@ async function run() { bundle.unmount(filtered); console.log("NewLogsV2 duplication tests passed"); - process.exit(0); -} - -run().catch((err) => { - console.error(err); - process.exit(1); }); diff --git a/application/frontend/src/lib/CommonList/CommonList.test.mjs b/application/frontend/src/lib/CommonList/CommonList.test.mjs index c1ca073..373fc45 100644 --- a/application/frontend/src/lib/CommonList/CommonList.test.mjs +++ b/application/frontend/src/lib/CommonList/CommonList.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; -import { bundleComponent, installDom, importBundle, settle } from "../../../test/harness.mjs"; +import { bundleComponent, installDom, importBundle, settle, main } from "../../../test/harness.mjs"; -async function run() { +main(async () => { const outfile = await bundleComponent("test/entry.js"); const { window } = installDom({}); const bundle = await importBundle(outfile); @@ -26,10 +26,4 @@ async function run() { bundle.unmount(filled); console.log("CommonList empty-list tests passed"); - process.exit(0); -} - -run().catch((err) => { - console.error(err); - process.exit(1); }); diff --git a/application/frontend/src/lib/GroupModal/GroupModal.test.mjs b/application/frontend/src/lib/GroupModal/GroupModal.test.mjs index 15784a9..6c90311 100644 --- a/application/frontend/src/lib/GroupModal/GroupModal.test.mjs +++ b/application/frontend/src/lib/GroupModal/GroupModal.test.mjs @@ -5,6 +5,7 @@ import { importBundle, settle, jsonResponse, + main, } from "../../../test/harness.mjs"; const HOSTS = [ @@ -52,7 +53,7 @@ function check(window, host, service) { return box; } -async function run() { +main(async () => { const outfile = await bundleComponent("test/entry.js"); const { window } = installDom({ fetchImpl: recordingFetch }); const bundle = await importBundle(outfile); @@ -227,10 +228,4 @@ async function run() { bundle.groupModalIsVisible.set(false); bundle.groupBeingEdited.set(null); console.log("GroupModal tests passed"); - process.exit(0); -} - -run().catch((err) => { - console.error(err); - process.exit(1); }); diff --git a/application/frontend/src/lib/ListWithChoise/ListWithChoise.test.mjs b/application/frontend/src/lib/ListWithChoise/ListWithChoise.test.mjs index ce066f9..2d89b7e 100644 --- a/application/frontend/src/lib/ListWithChoise/ListWithChoise.test.mjs +++ b/application/frontend/src/lib/ListWithChoise/ListWithChoise.test.mjs @@ -5,6 +5,7 @@ import { importBundle, settle, jsonResponse, + main, } from "../../../test/harness.mjs"; const HOSTS = [ @@ -32,7 +33,7 @@ function textOf(node) { return node.textContent.replace(/\s+/g, " ").trim(); } -async function run() { +main(async () => { const outfile = await bundleComponent("test/entry.js"); const { window } = installDom({ fetchImpl: async () => jsonResponse({ error: null }) }); const bundle = await importBundle(outfile); @@ -96,11 +97,53 @@ async function run() { bundle.unmount(app); bundle.groups.set([]); - console.log("ListWithChoise group rendering tests passed"); - process.exit(0); -} -run().catch((err) => { - console.error(err); - process.exit(1); + // The active and stopped service lists render from one shared snippet; their + // rows differ only by an id suffix, so both lists have to be checked. + const MIXED = [ + { + host: "host1", + services: [ + { serviceName: "api", isDisabled: false, isFavorite: true }, + { serviceName: "old", isDisabled: true, isFavorite: false }, + ], + }, + ]; + const tree = bundle.mount(bundle.ListWithChoise, { + target, + props: { listData: MIXED, listElementButton: "true" }, + }); + await settle(); + + const lists = [...target.querySelectorAll("ul.activeServices")]; + assert.equal(lists.length, 2, "expected an active list and a stopped list"); + + const active = [...lists[0].querySelectorAll(".serviceListItem")]; + const stopped = [...lists[1].querySelectorAll(".serviceListItem")]; + console.log( + ` active: ${active.map((r) => textOf(r.querySelector("p")))}, ` + + `stopped: ${stopped.map((r) => textOf(r.querySelector("p")))}` + ); + assert.deepEqual(active.map((r) => textOf(r.querySelector("p"))), ["api"]); + assert.deepEqual(stopped.map((r) => textOf(r.querySelector("p"))), ["old"]); + + assert.ok( + target.querySelector("#heartButton-0"), + "the active row lost its heart button id" + ); + assert.ok( + target.querySelector("#heartButtonDissabled-1"), + "the stopped row lost its suffixed heart button id" + ); + assert.ok( + target.querySelector("#heartButton-0").className.includes("log-Heart"), + "a favourited service should render a filled heart" + ); + assert.ok( + stopped[0].querySelector("p").className.includes("disabled"), + "a stopped service should render disabled" + ); + + bundle.unmount(tree); + console.log("ListWithChoise rendering tests passed"); }); diff --git a/application/frontend/test/harness.mjs b/application/frontend/test/harness.mjs index 6a2d36d..3dbc47c 100644 --- a/application/frontend/test/harness.mjs +++ b/application/frontend/test/harness.mjs @@ -242,3 +242,14 @@ export async function settle(rounds = 30) { export async function importBundle(outfile) { return import(pathToFileURL(outfile).href); } + +// Each test file is its own process, so one place owns the exit codes. +export function main(body) { + body().then( + () => process.exit(0), + (err) => { + console.error(err); + process.exit(1); + } + ); +} From 8ae90c4111ef25f7fdd588328f8fdd03b7c384e2 Mon Sep 17 00:00:00 2001 From: Roman Malenko Date: Sat, 8 Aug 2026 17:27:54 +0300 Subject: [PATCH 24/31] refactor(frontend): render both service lists from one row snippet --- .../lib/ListWithChoise/ListWithChoise.svelte | 160 ++++++------------ 1 file changed, 51 insertions(+), 109 deletions(-) diff --git a/application/frontend/src/lib/ListWithChoise/ListWithChoise.svelte b/application/frontend/src/lib/ListWithChoise/ListWithChoise.svelte index 84a8cf4..f18030c 100644 --- a/application/frontend/src/lib/ListWithChoise/ListWithChoise.svelte +++ b/application/frontend/src/lib/ListWithChoise/ListWithChoise.svelte @@ -198,6 +198,53 @@ }); +{#snippet serviceRow(host, service, i, idSuffix)} +
  • choseService(host, service.serviceName, target)} + > +
    +

    + {service.serviceName} +

    + {#if listElementButton} +
    +
    { + navigate( + `${changeKey}/servicesettings/${host.trim()}/${service.serviceName.trim()}`, + { replace: true } + ); + chosenElSettings = `${host.trim()}-${service.serviceName.trim()}`; + }} + > + +
    +
    favoriteToggle(host, service.serviceName)} + > + +
    +
    + {/if} +
    +
    +
  • +{/snippet} +
    {#if groupSections.length}
      @@ -294,61 +341,8 @@ >
        {#each listEl.services as service, i} - {#if !service.isDisabled}
      • - choseService(listEl.host, service.serviceName, target)} - > -
        -

        - {service.serviceName} -

        - {#if listElementButton} -
        -
        { - navigate( - `${changeKey}/servicesettings/${listEl.host.trim()}/${service.serviceName.trim()}`, - { replace: true } - ); - - chosenElSettings = `${listEl.host.trim()}-${service.serviceName.trim()}`; - }} - > - -
        -
        { - favoriteToggle(listEl.host, service.serviceName); - }} - > - -
        -
        - {/if} -
        -
        -
      • - {/if}{/each} + {#if !service.isDisabled}{@render serviceRow(listEl.host, service, i, "")}{/if} + {/each}
      {#each listEl.services as service, i} - {#if service.isDisabled}
    • - choseService(listEl.host, service.serviceName, target)} - > -
      -

      - {service.serviceName} -

      - {#if listElementButton} -
      -
      { - navigate( - `${changeKey}/servicesettings/${listEl.host.trim()}/${service.serviceName.trim()}`, - { replace: true } - ); - - chosenElSettings = `${listEl.host.trim()}-${service.serviceName.trim()}`; - }} - > - -
      -
      { - favoriteToggle(listEl.host, service.serviceName); - }} - > - -
      -
      - {/if} -
      -
      -
    • {/if}{/each} + {#if service.isDisabled}{@render serviceRow(listEl.host, service, i, "Dissabled")}{/if} + {/each}
    From 602ad660ebab2fd7f66bff4dc4430cb822cb676f Mon Sep 17 00:00:00 2001 From: Roman Malenko Date: Sat, 8 Aug 2026 17:28:52 +0300 Subject: [PATCH 25/31] refactor(backend): collapse duplicated logic into shared helpers --- application/backend/app/agent/agent.go | 44 +-- .../backend/app/containerdb/containerdb.go | 204 ++++------ .../app/containerdb/containerdb_test.go | 48 +-- .../backend/app/containerdb/cursor_test.go | 2 +- .../backend/app/containerdb/helpers_test.go | 12 + .../backend/app/containerdb/limit_test.go | 2 +- .../backend/app/containerdb/mutex_test.go | 4 +- .../backend/app/containerdb/reset_test.go | 6 +- .../backend/app/containerdb/retention_test.go | 2 +- application/backend/app/daemon/daemon.go | 93 ++--- .../backend/app/daemon/metricsstate_test.go | 9 +- .../backend/app/daemon/restart_test.go | 12 +- application/backend/app/db/db_test.go | 33 -- application/backend/app/metrics/collect.go | 45 +-- .../backend/app/routes/edituser_test.go | 12 +- application/backend/app/routes/groups_test.go | 120 ++---- .../backend/app/routes/helpers_test.go | 45 +++ .../backend/app/routes/loginlimit_test.go | 3 +- application/backend/app/routes/routes.go | 366 ++++++++---------- application/backend/app/routes/routes_test.go | 156 +++----- .../backend/app/routes/security_test.go | 87 +++-- .../backend/app/statistics/keys_test.go | 4 +- .../backend/app/statistics/race_test.go | 9 +- .../backend/app/statistics/registry_test.go | 85 ++++ .../backend/app/statistics/statistics.go | 107 ++--- .../backend/app/statistics/statistics_test.go | 72 ---- application/backend/app/streamer/streamer.go | 24 +- .../backend/app/streamer/streamer_test.go | 51 --- application/backend/app/userdb/userdb.go | 18 +- application/backend/app/util/util.go | 71 +--- application/backend/app/util/util_test.go | 43 -- application/backend/app/vars/state.go | 1 - application/backend/app/vars/vars.go | 5 - application/backend/main.go | 4 +- 34 files changed, 703 insertions(+), 1096 deletions(-) create mode 100644 application/backend/app/containerdb/helpers_test.go delete mode 100644 application/backend/app/db/db_test.go create mode 100644 application/backend/app/routes/helpers_test.go create mode 100644 application/backend/app/statistics/registry_test.go delete mode 100644 application/backend/app/statistics/statistics_test.go delete mode 100644 application/backend/app/streamer/streamer_test.go diff --git a/application/backend/app/agent/agent.go b/application/backend/app/agent/agent.go index b9fba0a..809b22d 100644 --- a/application/backend/app/agent/agent.go +++ b/application/backend/app/agent/agent.go @@ -10,18 +10,27 @@ import ( "github.com/devforth/OnLogs/app/util" ) -func SendInitRequest(containers []string) { - postBody, _ := json.Marshal(map[string]interface{}{ +func post(path string, body map[string]any) (*http.Response, error) { + payload, _ := json.Marshal(body) + return http.Post(os.Getenv("HOST")+path, "application/json", bytes.NewBuffer(payload)) +} + +func identity() map[string]any { + return map[string]any{ "Hostname": util.GetHost(), "Token": os.Getenv("ONLOGS_TOKEN"), - "Services": containers, - }) - responseBody := bytes.NewBuffer(postBody) + } +} + +func SendInitRequest(containers []string) { + body := identity() + body["Services"] = containers - resp, err := http.Post(os.Getenv("HOST")+"/api/v1/addHost", "application/json", responseBody) + resp, err := post("/api/v1/addHost", body) if err != nil { panic("ERROR: Can't send request to host: " + err.Error()) } + defer resp.Body.Close() if resp.StatusCode != 200 { b, _ := io.ReadAll(resp.Body) @@ -30,13 +39,12 @@ func SendInitRequest(containers []string) { } func SendLogMessage(token string, container string, message_item []string) bool { - postBody, _ := json.Marshal(map[string]interface{}{ + resp, err := post("/api/v1/addLogLine", map[string]any{ "Host": util.GetHost(), "Token": token, "LogLine": []string{message_item[0], message_item[1]}, "Container": container, }) - resp, err := http.Post(os.Getenv("HOST")+"/api/v1/addLogLine", "application/json", bytes.NewBuffer(postBody)) if err == nil { defer resp.Body.Close() } @@ -81,25 +89,17 @@ func resendContainerBuffer(token string, container string) bool { } func SendUpdate(containers []string) { - postBody, _ := json.Marshal(map[string]interface{}{ - "Hostname": util.GetHost(), - "Token": os.Getenv("ONLOGS_TOKEN"), - "Services": containers, - }) - responseBody := bytes.NewBuffer(postBody) + body := identity() + body["Services"] = containers - http.Post(os.Getenv("HOST")+"/api/v1/addHost", "application/json", responseBody) + if resp, err := post("/api/v1/addHost", body); err == nil { + resp.Body.Close() + } AskForDelete() } func AskForDelete() { - postBody, _ := json.Marshal(map[string]interface{}{ - "Hostname": util.GetHost(), - "Token": os.Getenv("ONLOGS_TOKEN"), - }) - responseBody := bytes.NewBuffer(postBody) - - resp, err := http.Post(os.Getenv("HOST")+"/api/v1/askForDelete", "application/json", responseBody) + resp, err := post("/api/v1/askForDelete", identity()) if err != nil { return } diff --git a/application/backend/app/containerdb/containerdb.go b/application/backend/app/containerdb/containerdb.go index 65ff1c9..c1593b1 100644 --- a/application/backend/app/containerdb/containerdb.go +++ b/application/backend/app/containerdb/containerdb.go @@ -44,6 +44,52 @@ func GetLogStatusKey(message string) string { return "other" } +func forEachContainer(visit func(host, container string)) error { + hosts, err := os.ReadDir("leveldb/hosts/") + if err != nil { + return fmt.Errorf("failed to read hosts directory: %v", err) + } + for _, h := range hosts { + containers, _ := os.ReadDir("leveldb/hosts/" + h.Name() + "/containers") + for _, c := range containers { + visit(h.Name(), c.Name()) + } + } + return nil +} + +// scanLimit 0 means scan every row. +func pruneUpTo(db *leveldb.DB, cutoff time.Time, scanLimit int) int { + batch := new(leveldb.Batch) + deleted := 0 + + iter := db.NewIterator(nil, nil) + scanned := 0 + for ok := iter.First(); ok && (scanLimit == 0 || scanned < scanLimit); ok = iter.Next() { + scanned++ + keyTime, err := time.Parse(time.RFC3339Nano, getDateTimeFromKey(string(iter.Key()))) + if err != nil { + fmt.Println("Error parsing key time:", err) + continue + } + if !keyTime.After(cutoff) { + batch.Delete(iter.Key()) + deleted++ + } + } + iter.Release() + + if deleted == 0 { + return 0 + } + if err := db.Write(batch, nil); err != nil { + fmt.Println("Failed to delete batch:", err) + return 0 + } + db.CompactRange(leveldbUtil.Range{Start: nil, Limit: nil}) + return deleted +} + func checkAndManageLogSize(host string, container string) error { maxSize, err := util.ParseHumanReadableSize(os.Getenv("MAX_LOGS_SIZE")) if err != nil { @@ -51,138 +97,63 @@ func checkAndManageLogSize(host string, container string) error { } for { - hosts, err := os.ReadDir("leveldb/hosts/") - if err != nil { - return fmt.Errorf("failed to read hosts directory: %v", err) - } - var totalSize int64 - for _, h := range hosts { - hostName := h.Name() - containers, _ := os.ReadDir("leveldb/hosts/" + hostName + "/containers") - for _, c := range containers { - containerName := c.Name() - size := util.GetDirSize(hostName, containerName) - totalSize += int64(size * 1024 * 1024) - } + if err := forEachContainer(func(h, c string) { + totalSize += int64(util.GetDirSize(h, c) * 1024 * 1024) + }); err != nil { + return err } - - // fmt.Printf("Max size: %d, current dir size: %d\n", maxSize, totalSize) if totalSize <= maxSize { - break + return nil } var cutoffKeys [][]byte - for _, h := range hosts { - hostName := h.Name() - containers, _ := os.ReadDir("leveldb/hosts/" + hostName + "/containers") - for _, c := range containers { - containerName := c.Name() - logsDB := util.GetDB(hostName, containerName, "logs") - if logsDB == nil { - continue - } - - cutoffKeysForContainer, err := getCutoffKeysForContainer(logsDB, 200) - if err != nil || len(cutoffKeysForContainer) == 0 { - continue - } - cutoffKeys = append(cutoffKeys, cutoffKeysForContainer) + if err := forEachContainer(func(h, c string) { + logsDB := util.GetDB(h, c, "logs") + if logsDB == nil { + return } + if key, err := getCutoffKeysForContainer(logsDB, 200); err == nil && len(key) > 0 { + cutoffKeys = append(cutoffKeys, key) + } + }); err != nil { + return err } if len(cutoffKeys) == 0 { fmt.Println("Nothing to delete, cutoff keys not found.") - break + return nil } - oldestCutoffKey := findOldestCutoffKey(cutoffKeys) - oldestTime, err := time.Parse(time.RFC3339Nano, getDateTimeFromKey(string(oldestCutoffKey))) + oldestTime, err := time.Parse(time.RFC3339Nano, getDateTimeFromKey(string(findOldestCutoffKey(cutoffKeys)))) if err != nil { fmt.Println("Error parsing oldest time:", err) - break + return nil } fmt.Println("Oldest time for deletion cutoff:", oldestTime) - for _, h := range hosts { - hostName := h.Name() - containers, _ := os.ReadDir("leveldb/hosts/" + hostName + "/containers") - for _, c := range containers { - containerName := c.Name() - logsDB := util.GetDB(hostName, containerName, "logs") - if logsDB == nil { + if err := forEachContainer(func(h, c string) { + // Everything the quota measures, or it can never come down. + for _, dbType := range util.PrunableDBs() { + db := util.GetDB(h, c, dbType) + if db == nil { continue } - - batch := new(leveldb.Batch) - deletedCount := 0 - iter := logsDB.NewIterator(nil, nil) - - count := 0 - for ok := iter.First(); ok && count < 200; ok = iter.Next() { - count++ - keyTime, err := time.Parse(time.RFC3339Nano, getDateTimeFromKey(string(iter.Key()))) - if err != nil { - fmt.Println("Error parsing key time:", err) - continue - } - if keyTime.Before(oldestTime) || keyTime.Equal(oldestTime) { - batch.Delete(iter.Key()) - deletedCount++ - } - } - iter.Release() - - if deletedCount > 0 { - err = logsDB.Write(batch, nil) - if err != nil { - fmt.Printf("Failed to delete batch in %s/%s: %v\n", hostName, containerName, err) - } else { - CountRetentionDeleted(deletedCount) - fmt.Printf("Deleted %d logs from %s/%s\n", deletedCount, hostName, containerName) - } - logsDB.CompactRange(leveldbUtil.Range{Start: nil, Limit: nil}) + scanLimit := 0 + if dbType == "logs" { + scanLimit = 200 } - - // Prune everything the quota measures, or the measurement can - // never come down. - for _, dbType := range []string{"statuses", "statistics"} { - statusesDB := util.GetDB(hostName, containerName, dbType) - if statusesDB == nil { - continue - } - batch := new(leveldb.Batch) - deletedCountStatuses := 0 - iter := statusesDB.NewIterator(nil, nil) - - for ok := iter.First(); ok; ok = iter.Next() { - keyTime, err := time.Parse(time.RFC3339Nano, getDateTimeFromKey(string(iter.Key()))) - if err != nil { - fmt.Println("Error parsing key time:", err) - continue - } - if keyTime.Before(oldestTime) || keyTime.Equal(oldestTime) { - batch.Delete(iter.Key()) - deletedCountStatuses++ - } - } - iter.Release() - - if deletedCountStatuses > 0 { - err := statusesDB.Write(batch, nil) - if err != nil { - fmt.Printf("Failed to delete batch in %s for %s/%s: %v\n", dbType, hostName, containerName, err) - } - statusesDB.CompactRange(leveldbUtil.Range{Start: nil, Limit: nil}) - } + if deleted := pruneUpTo(db, oldestTime, scanLimit); deleted > 0 && dbType == "logs" { + CountRetentionDeleted(deleted) + fmt.Printf("Deleted %d logs from %s/%s\n", deleted, h, c) } } + }); err != nil { + return err } time.Sleep(100 * time.Millisecond) } - - return nil } func getCutoffKeysForContainer(db *leveldb.DB, limit int) ([]byte, error) { @@ -273,7 +244,7 @@ func MaybeScheduleCleanup(host string, container string) { }() } -func newStatCounter() map[string]uint64 { +func NewStatCounter() map[string]uint64 { return map[string]uint64{"error": 0, "debug": 0, "info": 0, "warn": 0, "meta": 0, "other": 0} } @@ -293,7 +264,7 @@ func countLogStatus(location string, statusKey string) { defer vars.Mutex.Unlock() if vars.Container_Stat_Counter[location] == nil { - vars.Container_Stat_Counter[location] = newStatCounter() + vars.Container_Stat_Counter[location] = NewStatCounter() } vars.Container_Stat_Counter[location][statusKey]++ @@ -306,7 +277,7 @@ func countLogStatus(location string, statusKey string) { } return } - total = newStatCounter() + total = NewStatCounter() logLineCounts[location] = total } total[statusKey]++ @@ -398,10 +369,6 @@ func normalizeForSearch(s string, caseSensetivity bool) string { return s } -func fitsForSearch(logLine string, message string, caseSensetivity bool) bool { - return fitsNormalizedSearch(logLine, normalizeForSearch(message, caseSensetivity), caseSensetivity) -} - func fitsNormalizedSearch(logLine string, normalizedMessage string, caseSensetivity bool) bool { if normalizedMessage == "" { return true @@ -409,11 +376,6 @@ func fitsNormalizedSearch(logLine string, normalizedMessage string, caseSensetiv return strings.Contains(normalizeForSearch(logLine, caseSensetivity), normalizedMessage) } -func increaseAndMove(counter *int, move_direction func() bool) { - *counter++ - move_direction() -} - func getMoveDirection(getPrev bool, iter iterator.Iterator) func() bool { if !getPrev { return func() bool { return iter.Prev() } @@ -504,7 +466,8 @@ func GetLogs(getPrev bool, include bool, host string, container string, message key := iter.Key() if len(key) == 0 { to_return["is_end"] = true - increaseAndMove(&counter, move_direction) + counter++ + move_direction() continue } else { to_return["is_end"] = false @@ -536,7 +499,8 @@ func GetLogs(getPrev bool, include bool, host string, container string, message } logs = append(logs, []string{timeStr, value, keyStr}) - increaseAndMove(&counter, move_direction) + counter++ + move_direction() last_processed_key = keyStr } @@ -582,7 +546,7 @@ func DeleteContainer(host string, container string, fullDelete bool) { } vars.Mutex.Lock() - vars.Container_Stat_Counter[host+"/"+container] = newStatCounter() + vars.Container_Stat_Counter[host+"/"+container] = NewStatCounter() // Dropped, not zeroed: frees a slot against maxTrackedContainers. delete(logLineCounts, host+"/"+container) vars.Mutex.Unlock() diff --git a/application/backend/app/containerdb/containerdb_test.go b/application/backend/app/containerdb/containerdb_test.go index 95af9d1..8f925e6 100644 --- a/application/backend/app/containerdb/containerdb_test.go +++ b/application/backend/app/containerdb/containerdb_test.go @@ -20,16 +20,16 @@ func TestPutLogMessage(t *testing.T) { defer statusDB.Close() defer db.Close() - PutLogMessage(db, host, cont, []string{vars.Year + "-02-10T12:56:09.230421754Z", "vokAU6OdSulJGynsz wBaKssXuAPGk6ZFiQxq4sQHe7B9Q9RbTAy\r\n"}) - PutLogMessage(db, host, cont, []string{vars.Year + "-02-10T12:57:09.230421754Z", "ERROR wBaKssXuAPGk6ZFiQxq4sQHe7B9Q9RbTAy\r\n"}) - PutLogMessage(db, host, cont, []string{vars.Year + "-02-10T12:58:09.230421754Z", "WARN vokAU6OdSulJGynsz\r\n"}) - PutLogMessage(db, host, cont, []string{vars.Year + "-02-10T12:59:09.230421754Z", "DEBUG wBaKssXuAPGk6ZFiQxq4sQHe7B9Q9RbTAy\r\n"}) - PutLogMessage(db, host, cont, []string{vars.Year + "-02-10T12:59:59.230421754Z", "INFO fasdfasdfB&^*inuk\r\n"}) + PutLogMessage(db, host, cont, []string{testYear + "-02-10T12:56:09.230421754Z", "vokAU6OdSulJGynsz wBaKssXuAPGk6ZFiQxq4sQHe7B9Q9RbTAy\r\n"}) + PutLogMessage(db, host, cont, []string{testYear + "-02-10T12:57:09.230421754Z", "ERROR wBaKssXuAPGk6ZFiQxq4sQHe7B9Q9RbTAy\r\n"}) + PutLogMessage(db, host, cont, []string{testYear + "-02-10T12:58:09.230421754Z", "WARN vokAU6OdSulJGynsz\r\n"}) + PutLogMessage(db, host, cont, []string{testYear + "-02-10T12:59:09.230421754Z", "DEBUG wBaKssXuAPGk6ZFiQxq4sQHe7B9Q9RbTAy\r\n"}) + PutLogMessage(db, host, cont, []string{testYear + "-02-10T12:59:59.230421754Z", "INFO fasdfasdfB&^*inuk\r\n"}) keys := []string{ - vars.Year + "-02-10T12:56:09.230421754Z", vars.Year + "-02-10T12:57:09.230421754Z", - vars.Year + "-02-10T12:58:09.230421754Z", vars.Year + "-02-10T12:59:09.230421754Z", - vars.Year + "-02-10T12:59:59.230421754Z", + testYear + "-02-10T12:56:09.230421754Z", testYear + "-02-10T12:57:09.230421754Z", + testYear + "-02-10T12:58:09.230421754Z", testYear + "-02-10T12:59:09.230421754Z", + testYear + "-02-10T12:59:59.230421754Z", } for _, key := range keys { iter := db.NewIterator(nil, nil) @@ -58,7 +58,7 @@ func TestPutLogMessage(t *testing.T) { t.Error("Not expected error: ", r) } }() - PutLogMessage(db, "", cont, []string{vars.Year + "-02-10T12:57:09.230421754Z", "fasdf\r\n"}) + PutLogMessage(db, "", cont, []string{testYear + "-02-10T12:57:09.230421754Z", "fasdf\r\n"}) } func TestGetLogs(t *testing.T) { @@ -69,22 +69,22 @@ func TestGetLogs(t *testing.T) { vars.Statuses_DBs["Test/TestGetLogsCont"] = statusDB defer statusDB.Close() - PutLogMessage(db, "Test", "TestGetLogsCont", []string{vars.Year + "-02-10T12:57:09.230421754Z", "fasdf\r\n"}) - PutLogMessage(db, "Test", "TestGetLogsCont", []string{vars.Year + "-02-10T12:51:09.230421754Z", "fasdf\r\n"}) - PutLogMessage(db, "Test", "TestGetLogsCont", []string{vars.Year + "-02-10T12:52:09.230421754Z", "fasdf\r\n"}) - PutLogMessage(db, "Test", "TestGetLogsCont", []string{vars.Year + "-02-10T12:53:09.230421754Z", "fasdf\r\n"}) - PutLogMessage(db, "Test", "TestGetLogsCont", []string{vars.Year + "-02-10T12:54:09.230421754Z", "fasdf\r\n"}) + PutLogMessage(db, "Test", "TestGetLogsCont", []string{testYear + "-02-10T12:57:09.230421754Z", "fasdf\r\n"}) + PutLogMessage(db, "Test", "TestGetLogsCont", []string{testYear + "-02-10T12:51:09.230421754Z", "fasdf\r\n"}) + PutLogMessage(db, "Test", "TestGetLogsCont", []string{testYear + "-02-10T12:52:09.230421754Z", "fasdf\r\n"}) + PutLogMessage(db, "Test", "TestGetLogsCont", []string{testYear + "-02-10T12:53:09.230421754Z", "fasdf\r\n"}) + PutLogMessage(db, "Test", "TestGetLogsCont", []string{testYear + "-02-10T12:54:09.230421754Z", "fasdf\r\n"}) db.Close() var logs [][]string - logs = GetLogs(false, true, "Test", "TestGetLogsCont", "", 30, vars.Year+"-02-10T12:57:09.230421754Z", false, nil)["logs"].([][]string) + logs = GetLogs(false, true, "Test", "TestGetLogsCont", "", 30, testYear+"-02-10T12:57:09.230421754Z", false, nil)["logs"].([][]string) if len(logs) != 5 { t.Error("5 logItems must be returned!") } - if logs[0][0] != vars.Year+"-02-10T12:57:09.230421754Z" { + if logs[0][0] != testYear+"-02-10T12:57:09.230421754Z" { t.Error("Invalid first logItem datetime: ", logs[0][0]) } - if logs[4][0] != vars.Year+"-02-10T12:51:09.230421754Z" { + if logs[4][0] != testYear+"-02-10T12:51:09.230421754Z" { t.Error("Invalid last logItem datetime: ", logs[4][0]) } @@ -95,7 +95,7 @@ func TestGetLogs(t *testing.T) { if len(oldestRows) != 1 { t.Fatalf("expected one row when paging forward from the start, got %d", len(oldestRows)) } - if oldestRows[0][0] != vars.Year+"-02-10T12:51:09.230421754Z" { + if oldestRows[0][0] != testYear+"-02-10T12:51:09.230421754Z" { t.Error("Invalid first logItem datetime: ", oldestRows[0][0]) } @@ -108,21 +108,21 @@ func TestGetLogs(t *testing.T) { if len(logs) != 4 { t.Error("4 logItems must be returned!") } - if logs[0][0] != vars.Year+"-02-10T12:52:09.230421754Z" { + if logs[0][0] != testYear+"-02-10T12:52:09.230421754Z" { t.Error("Invalid first logItem datetime: ", logs[0][0]) } - if logs[3][0] != vars.Year+"-02-10T12:57:09.230421754Z" { + if logs[3][0] != testYear+"-02-10T12:57:09.230421754Z" { t.Error("Invalid last logItem datetime: ", logs[3][0]) } - logs = GetLogs(true, false, "Test", "TestGetLogsCont", "", 30, vars.Year+"-02-10T12:51:09.230421753Z", false, nil)["logs"].([][]string) + logs = GetLogs(true, false, "Test", "TestGetLogsCont", "", 30, testYear+"-02-10T12:51:09.230421753Z", false, nil)["logs"].([][]string) if len(logs) != 5 { t.Error("4 logItems must be returned!") } - if logs[0][0] != vars.Year+"-02-10T12:51:09.230421754Z" { + if logs[0][0] != testYear+"-02-10T12:51:09.230421754Z" { t.Error("Invalid first logItem datetime: ", logs[0][0]) } - if logs[4][0] != vars.Year+"-02-10T12:57:09.230421754Z" { + if logs[4][0] != testYear+"-02-10T12:57:09.230421754Z" { t.Error("Invalid last logItem datetime: ", logs[3][0]) } } @@ -154,7 +154,7 @@ func TestPutLogMessageSameTimestampAcrossRestart(t *testing.T) { statusDB, _ := leveldb.OpenFile("leveldb/hosts/"+host+"/containers/"+container+"/statuses", nil) vars.Statuses_DBs[location] = statusDB - ts := vars.Year + "-02-10T12:57:09.230421754Z" + ts := testYear + "-02-10T12:57:09.230421754Z" _ = PutLogMessage(db, host, container, []string{ts, "first"}) _ = PutLogMessage(db, host, container, []string{ts, "second"}) logKeyCounter.Store(0) diff --git a/application/backend/app/containerdb/cursor_test.go b/application/backend/app/containerdb/cursor_test.go index 0320dd1..590b80f 100644 --- a/application/backend/app/containerdb/cursor_test.go +++ b/application/backend/app/containerdb/cursor_test.go @@ -27,7 +27,7 @@ func seedSameMillisecondRows(t *testing.T, host, container string) []string { messages := []string{"first in the millisecond", "second in the millisecond", "third in the millisecond"} for _, message := range messages { - if err := PutLogMessage(db, host, container, []string{vars.Year + sameMillisecond, message}); err != nil { + if err := PutLogMessage(db, host, container, []string{testYear + sameMillisecond, message}); err != nil { t.Fatal(err) } } diff --git a/application/backend/app/containerdb/helpers_test.go b/application/backend/app/containerdb/helpers_test.go new file mode 100644 index 0000000..ee5956e --- /dev/null +++ b/application/backend/app/containerdb/helpers_test.go @@ -0,0 +1,12 @@ +package containerdb + +import ( + "strconv" + "time" +) + +var testYear = strconv.Itoa(time.Now().UTC().Year()) + +func fitsForSearch(logLine string, message string, caseSensetivity bool) bool { + return fitsNormalizedSearch(logLine, normalizeForSearch(message, caseSensetivity), caseSensetivity) +} diff --git a/application/backend/app/containerdb/limit_test.go b/application/backend/app/containerdb/limit_test.go index 1733b98..7070134 100644 --- a/application/backend/app/containerdb/limit_test.go +++ b/application/backend/app/containerdb/limit_test.go @@ -25,7 +25,7 @@ func seedLogs(t *testing.T, host, container string, n int) { vars.Statuses_DBs[host+"/"+container] = statusDB for i := 0; i < n; i++ { - ts := vars.Year + "-02-10T12:" + pad(i/60) + ":" + pad(i%60) + ".230421754Z" + ts := testYear + "-02-10T12:" + pad(i/60) + ":" + pad(i%60) + ".230421754Z" if err := PutLogMessage(db, host, container, []string{ts, "line " + strconv.Itoa(i)}); err != nil { t.Fatal(err) } diff --git a/application/backend/app/containerdb/mutex_test.go b/application/backend/app/containerdb/mutex_test.go index bc4bebf..43921e0 100644 --- a/application/backend/app/containerdb/mutex_test.go +++ b/application/backend/app/containerdb/mutex_test.go @@ -43,7 +43,7 @@ func TestPutLogMessageHandlesAContainerWithNoStatCounterYet(t *testing.T) { panicked := make(chan interface{}, 1) go func() { defer func() { panicked <- recover() }() - PutLogMessage(db, host, container, []string{vars.Year + "-02-10T12:56:09.230421754Z", "first line"}) + PutLogMessage(db, host, container, []string{testYear + "-02-10T12:56:09.230421754Z", "first line"}) }() if r := <-panicked; r != nil { @@ -92,7 +92,7 @@ func TestPutLogMessageCountsConcurrentlyWithoutRacing(t *testing.T) { done <- failure }() for n := 0; n < 25; n++ { - PutLogMessage(db, host, container, []string{vars.Year + "-02-10T12:56:09.230421754Z", "concurrent line"}) + PutLogMessage(db, host, container, []string{testYear + "-02-10T12:56:09.230421754Z", "concurrent line"}) } }() } diff --git a/application/backend/app/containerdb/reset_test.go b/application/backend/app/containerdb/reset_test.go index b41f593..2b7e34b 100644 --- a/application/backend/app/containerdb/reset_test.go +++ b/application/backend/app/containerdb/reset_test.go @@ -23,7 +23,7 @@ func TestDeleteContainerLeavesUsableHandlesBehind(t *testing.T) { util.GetDB(host, container, "statuses") util.GetDB(host, container, "statistics") - if err := PutLogMessage(logsDB, host, container, []string{vars.Year + "-02-10T12:56:09.230421754Z", "before delete"}); err != nil { + if err := PutLogMessage(logsDB, host, container, []string{testYear + "-02-10T12:56:09.230421754Z", "before delete"}); err != nil { t.Fatal(err) } @@ -45,7 +45,7 @@ func TestDeleteContainerLeavesUsableHandlesBehind(t *testing.T) { if reopened == nil { t.Fatal("the logs database could not be reopened after a delete") } - if err := PutLogMessage(reopened, host, container, []string{vars.Year + "-02-10T12:57:09.230421754Z", "after delete"}); err != nil { + if err := PutLogMessage(reopened, host, container, []string{testYear + "-02-10T12:57:09.230421754Z", "after delete"}); err != nil { t.Fatalf("ingestion failed after a delete: %v", err) } @@ -71,7 +71,7 @@ func TestLogsOfSameNamedContainersOnDifferentHostsStaySeparate(t *testing.T) { if db == nil { t.Fatalf("could not open the logs database for %s", host) } - if err := PutLogMessage(db, host, container, []string{vars.Year + "-02-10T12:56:09.230421754Z", "line from " + host}); err != nil { + if err := PutLogMessage(db, host, container, []string{testYear + "-02-10T12:56:09.230421754Z", "line from " + host}); err != nil { t.Fatal(err) } } diff --git a/application/backend/app/containerdb/retention_test.go b/application/backend/app/containerdb/retention_test.go index 673828c..33c3ff9 100644 --- a/application/backend/app/containerdb/retention_test.go +++ b/application/backend/app/containerdb/retention_test.go @@ -23,7 +23,7 @@ func TestRetentionMeasuresOnlyWhatItCanPrune(t *testing.T) { t.Fatal("could not open the logs database") } for i := 0; i < 5; i++ { - ts := vars.Year + "-02-10T12:5" + string(rune('0'+i)) + ":09.230421754Z" + ts := testYear + "-02-10T12:5" + string(rune('0'+i)) + ":09.230421754Z" if err := PutLogMessage(logsDB, host, container, []string{ts, "a log line"}); err != nil { t.Fatal(err) } diff --git a/application/backend/app/daemon/daemon.go b/application/backend/app/daemon/daemon.go index eae4479..00b9ea4 100644 --- a/application/backend/app/daemon/daemon.go +++ b/application/backend/app/daemon/daemon.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "maps" "os" "strconv" "strings" @@ -51,31 +52,26 @@ type DaemonService struct { cursorTimestamps map[string]time.Time } -func (h *DaemonService) ensureRuntimeState() { - h.streamsMu.Lock() - defer h.streamsMu.Unlock() - - if h.streamCancels == nil { - h.streamCancels = map[string]context.CancelFunc{} - } - if h.streamIDs == nil { - h.streamIDs = map[string]uint64{} - } - if h.recentFingerprints == nil { - h.recentFingerprints = map[string][]string{} - } - if h.recentSet == nil { - h.recentSet = map[string]map[string]struct{}{} +func NewDaemonService(dockerClient *docker.DockerService) *DaemonService { + return &DaemonService{ + DockerClient: dockerClient, + streamCancels: map[string]context.CancelFunc{}, + streamIDs: map[string]uint64{}, + recentFingerprints: map[string][]string{}, + recentSet: map[string]map[string]struct{}{}, + droppedReplays: map[string]int{}, + droppedReplayTotals: map[string]uint64{}, + cursorTimestamps: map[string]time.Time{}, } - if h.droppedReplays == nil { - h.droppedReplays = map[string]int{} - } - if h.droppedReplayTotals == nil { - h.droppedReplayTotals = map[string]uint64{} - } - if h.cursorTimestamps == nil { - h.cursorTimestamps = map[string]time.Time{} +} + +func noteMeta(db *leveldb.DB, host, containerName, token string, toHost bool, message string) { + if toHost { + agent.SendLogMessage(token, containerName, + strings.SplitN(createLogMessage(nil, host, containerName, message), " ", 2)) + return } + createLogMessage(db, host, containerName, message) } func createLogMessage(db *leveldb.DB, host string, container string, message string) string { @@ -86,10 +82,6 @@ func createLogMessage(db *leveldb.DB, host string, container string, message str return datetime + " " + message } -func closeActiveStream(containerName string) { - vars.RemoveActiveStream(containerName) -} - func normalizeTimestamp(raw string) (string, time.Time, error) { ts, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(raw)) if err != nil { @@ -154,7 +146,6 @@ func (h *DaemonService) seedBoundaryFingerprints(host, containerName string, cur } func (h *DaemonService) countDroppedReplay(containerName string) { - h.ensureRuntimeState() h.streamsMu.Lock() defer h.streamsMu.Unlock() @@ -176,24 +167,14 @@ func (h *DaemonService) DroppedReplays(containerName string) int { func (h *DaemonService) DroppedReplayTotals() map[string]uint64 { h.streamsMu.Lock() defer h.streamsMu.Unlock() - - totals := make(map[string]uint64, len(h.droppedReplayTotals)) - for location, count := range h.droppedReplayTotals { - totals[location] = count - } - return totals + return maps.Clone(h.droppedReplayTotals) } // Mirrors what saveCursor persisted, so a scrape needs no LevelDB. func (h *DaemonService) CursorTimestamps() map[string]time.Time { h.streamsMu.Lock() defer h.streamsMu.Unlock() - - cursors := make(map[string]time.Time, len(h.cursorTimestamps)) - for location, ts := range h.cursorTimestamps { - cursors[location] = ts - } - return cursors + return maps.Clone(h.cursorTimestamps) } func (h *DaemonService) isRecentDuplicate(containerName, fingerprint string) bool { @@ -233,7 +214,6 @@ func (h *DaemonService) storedThrough(host, containerName string) time.Time { // ingestLine stores one streamed line, dropping anything the previous run had // already persisted. Returns true when the line was stored. func (h *DaemonService) ingestLine(host, containerName string, currentDB *leveldb.DB, token string, toHost bool, storedThrough time.Time, line string) bool { - h.ensureRuntimeState() logItem, cursorTS, ok := parseDockerLogLine(line) if !ok { @@ -289,7 +269,6 @@ func (h *DaemonService) getResumeSince(host, containerName string) time.Time { } func (h *DaemonService) saveCursor(host, containerName string, ts time.Time) { - h.ensureRuntimeState() h.streamsMu.Lock() h.cursorTimestamps[host+"/"+containerName] = ts.UTC() @@ -415,7 +394,7 @@ func (h *DaemonService) runContainerStream(ctx context.Context, containerName st if err != nil { fmt.Println("ERROR: unable to attach logs stream for", containerName, ":", err) if h.finalizeStream(containerName, streamID) { - closeActiveStream(containerName) + vars.RemoveActiveStream(containerName) } return } @@ -428,37 +407,26 @@ func (h *DaemonService) runContainerStream(ctx context.Context, containerName st currentDB := util.GetDB(host, containerName, "logs") token := os.Getenv("ONLOGS_TOKEN") - if toHost { - agent.SendLogMessage(token, containerName, strings.SplitN(createLogMessage(nil, host, containerName, "ONLOGS: Container listening started!"), " ", 2)) - } else { - createLogMessage(currentDB, host, containerName, "ONLOGS: Container listening started!") - } + noteMeta(currentDB, host, containerName, token, toHost, "ONLOGS: Container listening started!") streamErr := h.streamDockerLogs(ctx, rc, func(line string) { h.ingestLine(host, containerName, currentDB, token, toHost, storedThrough, line) }, !h.isContainerTTY(ctx, containerName)) - if streamErr != nil && ctx.Err() == nil { - if toHost { - agent.SendLogMessage(token, containerName, strings.SplitN(createLogMessage(nil, host, containerName, "ONLOGS: Container listening stopped! ("+streamErr.Error()+")"), " ", 2)) - } else { - createLogMessage(currentDB, host, containerName, "ONLOGS: Container listening stopped! ("+streamErr.Error()+")") - } - } else if ctx.Err() == nil { - if toHost { - agent.SendLogMessage(token, containerName, strings.SplitN(createLogMessage(nil, host, containerName, "ONLOGS: Container listening stopped! (EOF)"), " ", 2)) - } else { - createLogMessage(currentDB, host, containerName, "ONLOGS: Container listening stopped! (EOF)") + if ctx.Err() == nil { + reason := "(EOF)" + if streamErr != nil { + reason = "(" + streamErr.Error() + ")" } + noteMeta(currentDB, host, containerName, token, toHost, "ONLOGS: Container listening stopped! "+reason) } if h.finalizeStream(containerName, streamID) { - closeActiveStream(containerName) + vars.RemoveActiveStream(containerName) } } func (h *DaemonService) EnsureStream(ctx context.Context, containerName string) { - h.ensureRuntimeState() h.streamsMu.Lock() if _, exists := h.streamCancels[containerName]; exists { @@ -482,7 +450,6 @@ func (h *DaemonService) EnsureStream(ctx context.Context, containerName string) } func (h *DaemonService) StopStream(containerName string) { - h.ensureRuntimeState() h.streamsMu.Lock() cancel, exists := h.streamCancels[containerName] @@ -495,7 +462,7 @@ func (h *DaemonService) StopStream(containerName string) { if exists { cancel() } - closeActiveStream(containerName) + vars.RemoveActiveStream(containerName) } // runningNames keeps only containers docker reports as running. Attaching to an diff --git a/application/backend/app/daemon/metricsstate_test.go b/application/backend/app/daemon/metricsstate_test.go index 7058610..8f91599 100644 --- a/application/backend/app/daemon/metricsstate_test.go +++ b/application/backend/app/daemon/metricsstate_test.go @@ -10,8 +10,7 @@ import ( // finalizeStream clears droppedReplays; the /metrics total must not follow it // down, or every stream restart looks like a counter reset. func TestDroppedReplayTotalsSurviveFinalize(t *testing.T) { - h := &DaemonService{} - h.ensureRuntimeState() + h := NewDaemonService(nil) h.streamsMu.Lock() h.streamIDs["c"] = 7 @@ -43,8 +42,7 @@ func TestDroppedReplayTotalsSurviveFinalize(t *testing.T) { } func TestCursorTimestampsRecorded(t *testing.T) { - h := &DaemonService{} - h.ensureRuntimeState() + h := NewDaemonService(nil) if len(h.CursorTimestamps()) != 0 { t.Fatal("expected no cursors before ingestion") @@ -69,8 +67,7 @@ func TestCursorTimestampsRecorded(t *testing.T) { } func TestCursorTimestampsDroppedOnFinalize(t *testing.T) { - h := &DaemonService{} - h.ensureRuntimeState() + h := NewDaemonService(nil) h.streamsMu.Lock() h.streamIDs["gone"] = 1 diff --git a/application/backend/app/daemon/restart_test.go b/application/backend/app/daemon/restart_test.go index 4627182..cd522d9 100644 --- a/application/backend/app/daemon/restart_test.go +++ b/application/backend/app/daemon/restart_test.go @@ -52,8 +52,7 @@ func TestRestartMidStreamStoresEachLineExactlyOnce(t *testing.T) { emitted := map[string]struct{}{} - first := &DaemonService{} - first.ensureRuntimeState() + first := NewDaemonService(nil) db := util.GetDB(host, container, "logs") if db == nil { t.Fatal("could not open the logs database") @@ -71,8 +70,7 @@ func TestRestartMidStreamStoresEachLineExactlyOnce(t *testing.T) { t.Fatalf("first run stored %d rows, expected %d", got, restartAfter) } - second := &DaemonService{} - second.ensureRuntimeState() + second := NewDaemonService(nil) restartCursor := second.storedThrough(host, container) if restartCursor.IsZero() { @@ -131,15 +129,13 @@ func TestRestartKeepsADistinctLineSharingTheCursorTimestamp(t *testing.T) { } ts := time.Date(2026, 2, 10, 12, 44, 3, 123456789, time.UTC).Format(time.RFC3339Nano) - first := &DaemonService{} - first.ensureRuntimeState() + first := NewDaemonService(nil) if !first.ingestLine(host, container, db, "", false, time.Time{}, ts+" first at this nanosecond") { t.Fatal("the first line was not stored") } - second := &DaemonService{} - second.ensureRuntimeState() + second := NewDaemonService(nil) cursor := second.storedThrough(host, container) second.seedBoundaryFingerprints(host, container, cursor) diff --git a/application/backend/app/db/db_test.go b/application/backend/app/db/db_test.go deleted file mode 100644 index 3a122a6..0000000 --- a/application/backend/app/db/db_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package db - -import ( - "testing" -) - -func TestIsTokenExists(t *testing.T) { - type args struct { - token string - } - tests := []struct { - name string - args args - want bool - }{ - {"Bad token", args{token: "fasdfadsf"}, false}, - {"Valid token", args{token: CreateOnLogsToken()}, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := IsTokenExists(tt.args.token); got != tt.want { - t.Errorf("IsTokenExists() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestCreateOnLogsToken(t *testing.T) { - token := CreateOnLogsToken() - if !IsTokenExists(token) { - t.Error("Invalid token") - } -} diff --git a/application/backend/app/metrics/collect.go b/application/backend/app/metrics/collect.go index 0cc7e92..1ae95b5 100644 --- a/application/backend/app/metrics/collect.go +++ b/application/backend/app/metrics/collect.go @@ -46,42 +46,39 @@ func writeStreamUp(w io.Writer) { } } -// Raw timestamp, not a derived lag: a quiet container's lag grows forever and -// means nothing. The alert pairs this with a log-line rate. -func writeCursors(w io.Writer, ds daemonState) { - if ds == nil { - return - } - cursors := ds.CursorTimestamps() - if len(cursors) == 0 { +// An empty map omits the family rather than exporting a zero, so a series only +// appears once there is something real behind it. +func writePerContainer[V any](w io.Writer, name, metricType, help string, values map[string]V, value func(V) int64) { + if len(values) == 0 { return } - writeHeader(w, "onlogs_stream_cursor_timestamp_seconds", "gauge", - "Timestamp of the newest log line ingested for a container.") - for _, location := range sortedKeys(cursors) { + writeHeader(w, name, metricType, help) + for _, location := range sortedKeys(values) { host, container := splitLocation(location) - fmt.Fprintf(w, "onlogs_stream_cursor_timestamp_seconds{host=\"%s\",container=\"%s\"} %d\n", - escapeLabelValue(host), escapeLabelValue(container), cursors[location].Unix()) + fmt.Fprintf(w, "%s{host=\"%s\",container=\"%s\"} %d\n", + name, escapeLabelValue(host), escapeLabelValue(container), value(values[location])) } } -func writeDroppedReplays(w io.Writer, ds daemonState) { +// Raw timestamp, not a derived lag: a quiet container's lag grows forever and +// means nothing. The alert pairs this with a log-line rate. +func writeCursors(w io.Writer, ds daemonState) { if ds == nil { return } - totals := ds.DroppedReplayTotals() - if len(totals) == 0 { - return - } + writePerContainer(w, "onlogs_stream_cursor_timestamp_seconds", "gauge", + "Timestamp of the newest log line ingested for a container.", + ds.CursorTimestamps(), func(ts time.Time) int64 { return ts.Unix() }) +} - writeHeader(w, "onlogs_dropped_replay_lines_total", "counter", - "Log lines dropped because a stream replayed something already stored.") - for _, location := range sortedKeys(totals) { - host, container := splitLocation(location) - fmt.Fprintf(w, "onlogs_dropped_replay_lines_total{host=\"%s\",container=\"%s\"} %d\n", - escapeLabelValue(host), escapeLabelValue(container), totals[location]) +func writeDroppedReplays(w io.Writer, ds daemonState) { + if ds == nil { + return } + writePerContainer(w, "onlogs_dropped_replay_lines_total", "counter", + "Log lines dropped because a stream replayed something already stored.", + ds.DroppedReplayTotals(), func(n uint64) int64 { return int64(n) }) } func writeProcess(w io.Writer) { diff --git a/application/backend/app/routes/edituser_test.go b/application/backend/app/routes/edituser_test.go index fce17ad..1269b42 100644 --- a/application/backend/app/routes/edituser_test.go +++ b/application/backend/app/routes/edituser_test.go @@ -14,7 +14,6 @@ import ( ) func TestEditUserActuallyChangesThePassword(t *testing.T) { - ctrl := initTestConfig() os.Setenv("ADMIN_USERNAME", "admin") userdb.CreateUser("rotateme", "the-old-password") @@ -24,7 +23,7 @@ func TestEditUserActuallyChangesThePassword(t *testing.T) { req, _ := http.NewRequest("POST", "/api/v1/editUser", bytes.NewBuffer(body)) req.AddCookie(&http.Cookie{Name: "onlogs-cookie", Value: util.CreateJWT("admin")}) rr := httptest.NewRecorder() - http.HandlerFunc(ctrl.EditUser).ServeHTTP(rr, req) + http.HandlerFunc(testCtrl.EditUser).ServeHTTP(rr, req) if code := rr.Result().StatusCode; code != http.StatusOK { t.Fatalf("editUser returned %d: %s", code, rr.Body.String()) @@ -45,7 +44,6 @@ func TestEditUserActuallyChangesThePassword(t *testing.T) { } func TestEditUserRejectsAnEmptyPassword(t *testing.T) { - ctrl := initTestConfig() os.Setenv("ADMIN_USERNAME", "admin") userdb.CreateUser("emptyrotate", "a-real-password") @@ -55,7 +53,7 @@ func TestEditUserRejectsAnEmptyPassword(t *testing.T) { req, _ := http.NewRequest("POST", "/api/v1/editUser", bytes.NewBuffer(body)) req.AddCookie(&http.Cookie{Name: "onlogs-cookie", Value: util.CreateJWT("admin")}) rr := httptest.NewRecorder() - http.HandlerFunc(ctrl.EditUser).ServeHTTP(rr, req) + http.HandlerFunc(testCtrl.EditUser).ServeHTTP(rr, req) if userdb.CheckUserPassword("emptyrotate", "") { t.Fatal("an empty password was accepted") @@ -66,7 +64,6 @@ func TestEditUserRejectsAnEmptyPassword(t *testing.T) { } func TestGetHostsReadsFavouritesPerHost(t *testing.T) { - ctrl := initTestConfig() os.RemoveAll("leveldb/hosts") os.MkdirAll("leveldb/hosts/FavHostA/containers/shared", 0o700) @@ -85,7 +82,7 @@ func TestGetHostsReadsFavouritesPerHost(t *testing.T) { req, _ := http.NewRequest("GET", "/api/v1/getHosts", nil) req.AddCookie(&http.Cookie{Name: "onlogs-cookie", Value: util.CreateJWT("someuser")}) rr := httptest.NewRecorder() - http.HandlerFunc(ctrl.GetHosts).ServeHTTP(rr, req) + http.HandlerFunc(testCtrl.GetHosts).ServeHTTP(rr, req) var hosts []struct { Host string `json:"host"` @@ -114,7 +111,6 @@ func TestGetHostsReadsFavouritesPerHost(t *testing.T) { } func TestFavouritesAreScopedToTheUserWhoSetThem(t *testing.T) { - ctrl := initTestConfig() userdb.CreateUser("favuser", "favpass") userdb.CreateUser("otheruser", "otherpass") t.Cleanup(func() { @@ -128,7 +124,7 @@ func TestFavouritesAreScopedToTheUserWhoSetThem(t *testing.T) { req, _ := http.NewRequest("POST", "/api/v1/changeFavorite", bytes.NewBuffer(body)) req.AddCookie(&http.Cookie{Name: "onlogs-cookie", Value: util.CreateJWT("favuser")}) rr := httptest.NewRecorder() - http.HandlerFunc(ctrl.ChangeFavourite).ServeHTTP(rr, req) + http.HandlerFunc(testCtrl.ChangeFavourite).ServeHTTP(rr, req) if code := rr.Result().StatusCode; code != http.StatusOK { t.Fatalf("changeFavorite returned %d: %s", code, rr.Body.String()) diff --git a/application/backend/app/routes/groups_test.go b/application/backend/app/routes/groups_test.go index 229b431..6127f1f 100644 --- a/application/backend/app/routes/groups_test.go +++ b/application/backend/app/routes/groups_test.go @@ -1,50 +1,14 @@ package routes import ( - "bytes" "encoding/json" - "io" "net/http" - "net/http/httptest" + "slices" "testing" "github.com/devforth/OnLogs/app/groups" - "github.com/devforth/OnLogs/app/util" ) -// An empty user means no cookie at all, which is what an unauthenticated request -// and a DISABLE_AUTH request both look like. -func groupRequest(t *testing.T, user string, method string, body interface{}) *http.Request { - t.Helper() - - var reader io.Reader - if body != nil { - raw, err := json.Marshal(body) - if err != nil { - t.Fatalf("marshalling the request body: %v", err) - } - reader = bytes.NewReader(raw) - } - - req, err := http.NewRequest(method, "/", reader) - if err != nil { - t.Fatalf("building the request: %v", err) - } - if user != "" { - req.AddCookie(&http.Cookie{Name: "onlogs-cookie", Value: util.CreateJWT(user)}) - } - return req -} - -func callGroupRoute(t *testing.T, handler http.HandlerFunc, req *http.Request) (int, []byte) { - t.Helper() - - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - body, _ := io.ReadAll(rr.Result().Body) - return rr.Result().StatusCode, body -} - func groupNames(t *testing.T, body []byte) []string { t.Helper() @@ -59,20 +23,10 @@ func groupNames(t *testing.T, body []byte) []string { return names } -func contains(values []string, wanted string) bool { - for _, value := range values { - if value == wanted { - return true - } - } - return false -} - func TestGroupsAreIsolatedPerUser(t *testing.T) { - ctrl := initTestConfig() t.Cleanup(func() { groups.Delete("testuser", "isolated") }) - status, body := callGroupRoute(t, ctrl.CreateGroup, groupRequest(t, "testuser", "POST", map[string]interface{}{ + status, body := call(t, testCtrl.CreateGroup, authedRequest(t, "testuser", "POST", "/", map[string]interface{}{ "name": "isolated", "members": []map[string]string{{"host": "Test1", "service": "containerTest1"}}, })) @@ -80,20 +34,20 @@ func TestGroupsAreIsolatedPerUser(t *testing.T) { t.Fatalf("creating the group returned %d: %s", status, body) } - status, body = callGroupRoute(t, ctrl.GetGroups, groupRequest(t, "viewer", "GET", nil)) + status, body = call(t, testCtrl.GetGroups, authedRequest(t, "viewer", "GET", "/", nil)) if status != http.StatusOK { t.Fatalf("viewer's getGroups returned %d: %s", status, body) } - if contains(groupNames(t, body), "isolated") { + if slices.Contains(groupNames(t, body), "isolated") { t.Fatalf("viewer can read testuser's group: %s", body) } - callGroupRoute(t, ctrl.DeleteGroup, groupRequest(t, "viewer", "POST", map[string]string{"name": "isolated"})) + call(t, testCtrl.DeleteGroup, authedRequest(t, "viewer", "POST", "/", map[string]string{"name": "isolated"})) if _, found, _ := groups.Load("testuser", "isolated"); !found { t.Fatal("viewer's deleteGroup removed testuser's group") } - callGroupRoute(t, ctrl.UpdateGroup, groupRequest(t, "viewer", "POST", map[string]interface{}{ + call(t, testCtrl.UpdateGroup, authedRequest(t, "viewer", "POST", "/", map[string]interface{}{ "name": "isolated", "members": []map[string]string{}, })) @@ -104,13 +58,12 @@ func TestGroupsAreIsolatedPerUser(t *testing.T) { } func TestGroupCRUDRoundTrip(t *testing.T) { - ctrl := initTestConfig() t.Cleanup(func() { groups.Delete("testuser", "backend") groups.Delete("testuser", "backend renamed") }) - status, body := callGroupRoute(t, ctrl.CreateGroup, groupRequest(t, "testuser", "POST", map[string]interface{}{ + status, body := call(t, testCtrl.CreateGroup, authedRequest(t, "testuser", "POST", "/", map[string]interface{}{ "name": "backend", "members": []map[string]string{ {"host": "Test1", "service": "containerTest1"}, @@ -121,12 +74,12 @@ func TestGroupCRUDRoundTrip(t *testing.T) { t.Fatalf("createGroup returned %d: %s", status, body) } - status, body = callGroupRoute(t, ctrl.GetGroups, groupRequest(t, "testuser", "GET", nil)) - if status != http.StatusOK || !contains(groupNames(t, body), "backend") { + status, body = call(t, testCtrl.GetGroups, authedRequest(t, "testuser", "GET", "/", nil)) + if status != http.StatusOK || !slices.Contains(groupNames(t, body), "backend") { t.Fatalf("getGroups returned %d without the new group: %s", status, body) } - status, body = callGroupRoute(t, ctrl.UpdateGroup, groupRequest(t, "testuser", "POST", map[string]interface{}{ + status, body = call(t, testCtrl.UpdateGroup, authedRequest(t, "testuser", "POST", "/", map[string]interface{}{ "name": "backend", "newName": "backend renamed", "members": []map[string]string{{"host": "Test1", "service": "containerTest1"}}, @@ -143,7 +96,7 @@ func TestGroupCRUDRoundTrip(t *testing.T) { t.Fatalf("after the update the group holds %v", members) } - status, body = callGroupRoute(t, ctrl.DeleteGroup, groupRequest(t, "testuser", "POST", map[string]string{ + status, body = call(t, testCtrl.DeleteGroup, authedRequest(t, "testuser", "POST", "/", map[string]string{ "name": "backend renamed", })) if status != http.StatusOK { @@ -155,7 +108,6 @@ func TestGroupCRUDRoundTrip(t *testing.T) { } func TestGroupRoutesRejectUnauthenticated(t *testing.T) { - ctrl := initTestConfig() for _, route := range []struct { name string @@ -163,13 +115,13 @@ func TestGroupRoutesRejectUnauthenticated(t *testing.T) { method string body interface{} }{ - {"getGroups", ctrl.GetGroups, "GET", nil}, - {"createGroup", ctrl.CreateGroup, "POST", map[string]interface{}{"name": "nope", "members": []string{}}}, - {"updateGroup", ctrl.UpdateGroup, "POST", map[string]interface{}{"name": "nope", "members": []string{}}}, - {"deleteGroup", ctrl.DeleteGroup, "POST", map[string]string{"name": "nope"}}, + {"getGroups", testCtrl.GetGroups, "GET", nil}, + {"createGroup", testCtrl.CreateGroup, "POST", map[string]interface{}{"name": "nope", "members": []string{}}}, + {"updateGroup", testCtrl.UpdateGroup, "POST", map[string]interface{}{"name": "nope", "members": []string{}}}, + {"deleteGroup", testCtrl.DeleteGroup, "POST", map[string]string{"name": "nope"}}, } { t.Run(route.name, func(t *testing.T) { - status, body := callGroupRoute(t, route.handler, groupRequest(t, "", route.method, route.body)) + status, body := call(t, route.handler, authedRequest(t, "", route.method, "/", route.body)) if status != http.StatusUnauthorized { t.Fatalf("%s returned %d for an unauthenticated request: %s", route.name, status, body) } @@ -183,11 +135,10 @@ func TestGroupRoutesRejectUnauthenticated(t *testing.T) { } func TestCreateGroupRejectsDuplicateName(t *testing.T) { - ctrl := initTestConfig() t.Cleanup(func() { groups.Delete("testuser", "duplicated") }) create := func() (int, []byte) { - return callGroupRoute(t, ctrl.CreateGroup, groupRequest(t, "testuser", "POST", map[string]interface{}{ + return call(t, testCtrl.CreateGroup, authedRequest(t, "testuser", "POST", "/", map[string]interface{}{ "name": "duplicated", "members": []map[string]string{{"host": "Test1", "service": "containerTest1"}}, })) @@ -202,9 +153,8 @@ func TestCreateGroupRejectsDuplicateName(t *testing.T) { } func TestUpdateGroupOnMissingGroupIsNotFound(t *testing.T) { - ctrl := initTestConfig() - status, body := callGroupRoute(t, ctrl.UpdateGroup, groupRequest(t, "testuser", "POST", map[string]interface{}{ + status, body := call(t, testCtrl.UpdateGroup, authedRequest(t, "testuser", "POST", "/", map[string]interface{}{ "name": "never created", "members": []map[string]string{{"host": "Test1", "service": "containerTest1"}}, })) @@ -219,10 +169,9 @@ func TestUpdateGroupOnMissingGroupIsNotFound(t *testing.T) { // The UI can double-fire delete, so the second one is not an error. func TestDeleteGroupIsIdempotent(t *testing.T) { - ctrl := initTestConfig() for attempt := 0; attempt < 2; attempt++ { - status, body := callGroupRoute(t, ctrl.DeleteGroup, groupRequest(t, "testuser", "POST", map[string]string{ + status, body := call(t, testCtrl.DeleteGroup, authedRequest(t, "testuser", "POST", "/", map[string]string{ "name": "was never there", })) if status != http.StatusOK { @@ -232,41 +181,40 @@ func TestDeleteGroupIsIdempotent(t *testing.T) { } func TestGroupRoutesRejectMalformedInput(t *testing.T) { - ctrl := initTestConfig() cases := []struct { label string handler http.HandlerFunc body interface{} }{ - {"name carrying the key separator", ctrl.CreateGroup, map[string]interface{}{ + {"name carrying the key separator", testCtrl.CreateGroup, map[string]interface{}{ "name": "web\x00admin", "members": []map[string]string{}, }}, - {"empty name", ctrl.CreateGroup, map[string]interface{}{ + {"empty name", testCtrl.CreateGroup, map[string]interface{}{ "name": "", "members": []map[string]string{}, }}, - {"oversized name", ctrl.CreateGroup, map[string]interface{}{ + {"oversized name", testCtrl.CreateGroup, map[string]interface{}{ "name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "members": []map[string]string{}, }}, - {"member escaping its directory", ctrl.CreateGroup, map[string]interface{}{ + {"member escaping its directory", testCtrl.CreateGroup, map[string]interface{}{ "name": "traversal", "members": []map[string]string{{"host": "..", "service": "api"}}, }}, - {"member with a path separator", ctrl.CreateGroup, map[string]interface{}{ + {"member with a path separator", testCtrl.CreateGroup, map[string]interface{}{ "name": "separator", "members": []map[string]string{{"host": "Test1", "service": "a/b"}}, }}, - {"member with an empty service", ctrl.CreateGroup, map[string]interface{}{ + {"member with an empty service", testCtrl.CreateGroup, map[string]interface{}{ "name": "empty member", "members": []map[string]string{{"host": "Test1", "service": ""}}, }}, - {"delete with a forged name", ctrl.DeleteGroup, map[string]string{"name": "web\x00admin"}}, - {"update with a forged new name", ctrl.UpdateGroup, map[string]interface{}{ + {"delete with a forged name", testCtrl.DeleteGroup, map[string]string{"name": "web\x00admin"}}, + {"update with a forged new name", testCtrl.UpdateGroup, map[string]interface{}{ "name": "backend", "newName": "web\x00admin", "members": []map[string]string{}, }}, } for _, c := range cases { t.Run(c.label, func(t *testing.T) { - status, body := callGroupRoute(t, c.handler, groupRequest(t, "testuser", "POST", c.body)) + status, body := call(t, c.handler, authedRequest(t, "testuser", "POST", "/", c.body)) if status != http.StatusBadRequest { t.Fatalf("returned %d, want 400: %s", status, body) } @@ -275,7 +223,6 @@ func TestGroupRoutesRejectMalformedInput(t *testing.T) { } func TestCreateGroupCapsGroupsPerUser(t *testing.T) { - ctrl := initTestConfig() names := []string{} t.Cleanup(func() { @@ -293,7 +240,7 @@ func TestCreateGroupCapsGroupsPerUser(t *testing.T) { } names = append(names, "one too many") - status, body := callGroupRoute(t, ctrl.CreateGroup, groupRequest(t, "someuser", "POST", map[string]interface{}{ + status, body := call(t, testCtrl.CreateGroup, authedRequest(t, "someuser", "POST", "/", map[string]interface{}{ "name": "one too many", "members": []map[string]string{}, })) @@ -309,11 +256,10 @@ func TestCreateGroupCapsGroupsPerUser(t *testing.T) { // returns "" for a cookie-less request, so every such user shares one bucket. // Favourites already behave this way. This pins it as a decision. func TestDisableAuthSharesOneGroupBucket(t *testing.T) { - ctrl := initTestConfig() t.Setenv("DISABLE_AUTH", "true") t.Cleanup(func() { groups.Delete("", "shared bucket") }) - status, body := callGroupRoute(t, ctrl.CreateGroup, groupRequest(t, "", "POST", map[string]interface{}{ + status, body := call(t, testCtrl.CreateGroup, authedRequest(t, "", "POST", "/", map[string]interface{}{ "name": "shared bucket", "members": []map[string]string{{"host": "Test1", "service": "containerTest1"}}, })) @@ -321,12 +267,12 @@ func TestDisableAuthSharesOneGroupBucket(t *testing.T) { t.Fatalf("createGroup under DISABLE_AUTH returned %d: %s", status, body) } - status, body = callGroupRoute(t, ctrl.GetGroups, groupRequest(t, "", "GET", nil)) - if status != http.StatusOK || !contains(groupNames(t, body), "shared bucket") { + status, body = call(t, testCtrl.GetGroups, authedRequest(t, "", "GET", "/", nil)) + if status != http.StatusOK || !slices.Contains(groupNames(t, body), "shared bucket") { t.Fatalf("a second anonymous session did not see the group: %d %s", status, body) } - status, body = callGroupRoute(t, ctrl.CreateGroup, groupRequest(t, "", "POST", map[string]interface{}{ + status, body = call(t, testCtrl.CreateGroup, authedRequest(t, "", "POST", "/", map[string]interface{}{ "name": "shared bucket", "members": []map[string]string{}, })) diff --git a/application/backend/app/routes/helpers_test.go b/application/backend/app/routes/helpers_test.go new file mode 100644 index 0000000..ae10821 --- /dev/null +++ b/application/backend/app/routes/helpers_test.go @@ -0,0 +1,45 @@ +package routes + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/devforth/OnLogs/app/util" +) + +// An empty user means no cookie at all, which is what an unauthenticated +// request and a DISABLE_AUTH request both look like. +func authedRequest(t *testing.T, user, method, target string, body any) *http.Request { + t.Helper() + + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshalling the request body: %v", err) + } + reader = bytes.NewReader(raw) + } + + req, err := http.NewRequest(method, target, reader) + if err != nil { + t.Fatalf("building the request: %v", err) + } + if user != "" { + req.AddCookie(&http.Cookie{Name: "onlogs-cookie", Value: util.CreateJWT(user)}) + } + return req +} + +func call(t *testing.T, handler http.HandlerFunc, req *http.Request) (int, []byte) { + t.Helper() + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + body, _ := io.ReadAll(rr.Result().Body) + return rr.Result().StatusCode, body +} diff --git a/application/backend/app/routes/loginlimit_test.go b/application/backend/app/routes/loginlimit_test.go index e2947fc..4f01199 100644 --- a/application/backend/app/routes/loginlimit_test.go +++ b/application/backend/app/routes/loginlimit_test.go @@ -88,7 +88,6 @@ func TestLoginLimiterStillThrottlesRepeatedFailures(t *testing.T) { // The lockout lived in which key the handler chose, so it has to be exercised // through the handler. func TestLoginLockoutCannotBeInflictedOnAnotherUser(t *testing.T) { - ctrl := initTestConfig() userdb.CreateUser("victimaccount", "the-real-password") t.Cleanup(func() { userdb.DeleteUser("victimaccount") }) @@ -101,7 +100,7 @@ func TestLoginLockoutCannotBeInflictedOnAnotherUser(t *testing.T) { req, _ := http.NewRequest("POST", "/api/v1/login", bytes.NewBuffer(body)) req.RemoteAddr = addr + ":40000" rr := httptest.NewRecorder() - http.HandlerFunc(ctrl.Login).ServeHTTP(rr, req) + http.HandlerFunc(testCtrl.Login).ServeHTTP(rr, req) return rr.Result().StatusCode } diff --git a/application/backend/app/routes/routes.go b/application/backend/app/routes/routes.go index 7faee02..e7f9ece 100644 --- a/application/backend/app/routes/routes.go +++ b/application/backend/app/routes/routes.go @@ -11,6 +11,7 @@ import ( "net/url" "os" "path/filepath" + "slices" "strconv" "strings" "time" @@ -32,17 +33,15 @@ type RouteController struct { DaemonService *daemon.DaemonService } -func enableCors(w *http.ResponseWriter) { +func enableCors(w http.ResponseWriter) { var origin string if os.Getenv("ENV_NAME") == "local" { origin = "http://localhost:5173" - } else { - origin = "" } - (*w).Header().Set("Access-Control-Allow-Origin", origin) - (*w).Header().Set("Access-Control-Allow-Credentials", "true") - (*w).Header().Set("Access-Control-Allow-Methods", "*") - (*w).Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Methods", "*") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") } func isAllowedOrigin(req *http.Request) bool { @@ -60,40 +59,87 @@ func isAllowedOrigin(req *http.Request) bool { return strings.EqualFold(parsed.Host, req.Host) } -func verifyAdminUser(w *http.ResponseWriter, req *http.Request) bool { +func verifyAdminUser(w http.ResponseWriter, req *http.Request) bool { if os.Getenv("DISABLE_AUTH") == "true" { return true } username, err := util.GetUserFromJWT(*req) if username != os.Getenv("ADMIN_USERNAME") { - (*w).WriteHeader(http.StatusForbidden) - json.NewEncoder(*w).Encode(map[string]string{"error": "Only admin can perform this request"}) + fail(w, http.StatusForbidden, "Only admin can perform this request") return false } if err != nil { - (*w).WriteHeader(http.StatusUnauthorized) - json.NewEncoder(*w).Encode(map[string]string{"error": err.Error()}) + fail(w, http.StatusUnauthorized, err.Error()) return false } return true } -func verifyUser(w *http.ResponseWriter, req *http.Request) bool { +func verifyUser(w http.ResponseWriter, req *http.Request) bool { if os.Getenv("DISABLE_AUTH") == "true" { return true } - _, err := util.GetUserFromJWT(*req) - if err != nil { - (*w).WriteHeader(http.StatusUnauthorized) - json.NewEncoder(*w).Encode(map[string]string{"error": err.Error()}) + if _, err := util.GetUserFromJWT(*req); err != nil { + fail(w, http.StatusUnauthorized, err.Error()) return false } return true } +type authLevel int + +const ( + authNone authLevel = iota + authUser + authAdmin +) + +// False means the request must not reach the handler body: a CORS preflight, a +// rejected credential, or the wrong method. An empty method accepts any. +func guard(w http.ResponseWriter, req *http.Request, level authLevel, method string) bool { + enableCors(w) + if req.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return false + } + + switch level { + case authUser: + if !verifyUser(w, req) { + return false + } + case authAdmin: + if !verifyAdminUser(w, req) { + return false + } + } + + if method != "" && req.Method != method { + w.WriteHeader(http.StatusNotFound) + return false + } + return true +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + if status != http.StatusOK { + w.WriteHeader(status) + } + json.NewEncoder(w).Encode(payload) +} + +func ok(w http.ResponseWriter) { + writeJSON(w, http.StatusOK, map[string]any{"error": nil}) +} + +func fail(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + const ( maxRequestBody = 1 << 20 sessionLifetime = 48 * time.Hour @@ -106,23 +152,12 @@ func isTLS(req *http.Request) bool { func decodeBody(w http.ResponseWriter, req *http.Request, target interface{}) bool { req.Body = http.MaxBytesReader(w, req.Body, maxRequestBody) if err := json.NewDecoder(req.Body).Decode(target); err != nil { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"error": "Invalid request body"}) + fail(w, http.StatusBadRequest, "Invalid request body") return false } return true } -func verifyRequest(w *http.ResponseWriter, req *http.Request) bool { - enableCors(w) - if req.Method == "OPTIONS" { - (*w).WriteHeader(http.StatusOK) - return true - } - return false -} - func (h *RouteController) Frontend(w http.ResponseWriter, req *http.Request) { // http.Dir sanitises the name it is given but never its own root. dir := http.Dir("dist") @@ -162,11 +197,10 @@ func (h *RouteController) Frontend(w http.ResponseWriter, req *http.Request) { } func (h *RouteController) CheckCookie(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { + if !guard(w, req, authUser, "") { return } - w.Header().Add("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) + ok(w) } func (h *RouteController) AddLogLine(w http.ResponseWriter, req *http.Request) { @@ -240,12 +274,7 @@ func (h *RouteController) AddHost(w http.ResponseWriter, req *http.Request) { } func (h *RouteController) ChangeFavourite(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { - return - } - - if req.Method != "POST" { - w.WriteHeader(http.StatusNotFound) + if !guard(w, req, authUser, "POST") { return } @@ -269,43 +298,30 @@ func (h *RouteController) ChangeFavourite(w http.ResponseWriter, req *http.Reque } } - w.Header().Add("Content-Type", "application/json") if err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + fail(w, http.StatusInternalServerError, err.Error()) return } - json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) -} - -func groupError(w http.ResponseWriter, status int, message string) { - w.WriteHeader(status) - json.NewEncoder(w).Encode(map[string]string{"error": message}) + ok(w) } func (h *RouteController) GetGroups(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { + if !guard(w, req, authUser, "") { return } username, _ := util.GetUserFromJWT(*req) list, err := groups.List(username) - w.Header().Add("Content-Type", "application/json") if err != nil { - groupError(w, http.StatusInternalServerError, err.Error()) + fail(w, http.StatusInternalServerError, err.Error()) return } - json.NewEncoder(w).Encode(list) + writeJSON(w, http.StatusOK, list) } func (h *RouteController) CreateGroup(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { - return - } - - if req.Method != "POST" { - w.WriteHeader(http.StatusNotFound) + if !guard(w, req, authUser, "POST") { return } @@ -317,49 +333,43 @@ func (h *RouteController) CreateGroup(w http.ResponseWriter, req *http.Request) return } - w.Header().Add("Content-Type", "application/json") if err := groups.ValidateGroupName(body.Name); err != nil { - groupError(w, http.StatusBadRequest, err.Error()) + fail(w, http.StatusBadRequest, err.Error()) return } if err := groups.ValidateMembers(body.Members); err != nil { - groupError(w, http.StatusBadRequest, err.Error()) + fail(w, http.StatusBadRequest, err.Error()) return } username, _ := util.GetUserFromJWT(*req) existing, err := groups.List(username) if err != nil { - groupError(w, http.StatusInternalServerError, err.Error()) + fail(w, http.StatusInternalServerError, err.Error()) return } for _, group := range existing { if group.Name == body.Name { - groupError(w, http.StatusConflict, "Group \""+body.Name+"\" already exists") + fail(w, http.StatusConflict, "Group \""+body.Name+"\" already exists") return } } // An authenticated user must not be able to write unbounded data to LevelDB. if len(existing) >= groups.MaxGroupsPerUser { - groupError(w, http.StatusBadRequest, + fail(w, http.StatusBadRequest, fmt.Sprintf("You can not have more than %d groups", groups.MaxGroupsPerUser)) return } if err := groups.Store(username, body.Name, body.Members); err != nil { - groupError(w, http.StatusInternalServerError, err.Error()) + fail(w, http.StatusInternalServerError, err.Error()) return } - json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) + ok(w) } func (h *RouteController) UpdateGroup(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { - return - } - - if req.Method != "POST" { - w.WriteHeader(http.StatusNotFound) + if !guard(w, req, authUser, "POST") { return } @@ -377,58 +387,52 @@ func (h *RouteController) UpdateGroup(w http.ResponseWriter, req *http.Request) newName = body.Name } - w.Header().Add("Content-Type", "application/json") if err := groups.ValidateGroupName(body.Name); err != nil { - groupError(w, http.StatusBadRequest, err.Error()) + fail(w, http.StatusBadRequest, err.Error()) return } if err := groups.ValidateGroupName(newName); err != nil { - groupError(w, http.StatusBadRequest, err.Error()) + fail(w, http.StatusBadRequest, err.Error()) return } if err := groups.ValidateMembers(body.Members); err != nil { - groupError(w, http.StatusBadRequest, err.Error()) + fail(w, http.StatusBadRequest, err.Error()) return } username, _ := util.GetUserFromJWT(*req) _, found, err := groups.Load(username, body.Name) if err != nil { - groupError(w, http.StatusInternalServerError, err.Error()) + fail(w, http.StatusInternalServerError, err.Error()) return } if !found { - groupError(w, http.StatusNotFound, "No such group") + fail(w, http.StatusNotFound, "No such group") return } if newName != body.Name { if _, taken, _ := groups.Load(username, newName); taken { - groupError(w, http.StatusConflict, "Group \""+newName+"\" already exists") + fail(w, http.StatusConflict, "Group \""+newName+"\" already exists") return } } if err := groups.Store(username, newName, body.Members); err != nil { - groupError(w, http.StatusInternalServerError, err.Error()) + fail(w, http.StatusInternalServerError, err.Error()) return } if newName != body.Name { if err := groups.Delete(username, body.Name); err != nil { - groupError(w, http.StatusInternalServerError, err.Error()) + fail(w, http.StatusInternalServerError, err.Error()) return } } - json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) + ok(w) } func (h *RouteController) DeleteGroup(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { - return - } - - if req.Method != "POST" { - w.WriteHeader(http.StatusNotFound) + if !guard(w, req, authUser, "POST") { return } @@ -439,37 +443,30 @@ func (h *RouteController) DeleteGroup(w http.ResponseWriter, req *http.Request) return } - w.Header().Add("Content-Type", "application/json") if err := groups.ValidateGroupName(body.Name); err != nil { - groupError(w, http.StatusBadRequest, err.Error()) + fail(w, http.StatusBadRequest, err.Error()) return } username, _ := util.GetUserFromJWT(*req) // Deleting what is not there is a success, because the UI may double-fire. if err := groups.Delete(username, body.Name); err != nil { - groupError(w, http.StatusInternalServerError, err.Error()) + fail(w, http.StatusInternalServerError, err.Error()) return } - json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) + ok(w) } func (h *RouteController) GetSecret(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyAdminUser(&w, req) { + if !guard(w, req, authAdmin, "") { return } - w.Header().Add("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"token": db.CreateOnLogsToken()}) + writeJSON(w, http.StatusOK, map[string]string{"token": db.CreateOnLogsToken()}) } func (h *RouteController) GetChartData(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { - return - } - - if req.Method != "POST" { - w.WriteHeader(http.StatusNotFound) + if !guard(w, req, authUser, "POST") { return } @@ -483,23 +480,17 @@ func (h *RouteController) GetChartData(w http.ResponseWriter, req *http.Request) return } - if !util.Contains(data.Unit, []string{"hour", "day", "month"}) { - w.Header().Add("Content-Type", "application/json") + if !slices.Contains([]string{"hour", "day", "month"}, data.Unit) { w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]interface{}{"error": "Invalid data!"}) + writeJSON(w, http.StatusOK, map[string]interface{}{"error": "Invalid data!"}) return } - w.Header().Add("Content-Type", "application/json") - e, _ := json.Marshal( - statistics.GetChartData( - data.Host, data.Service, data.Unit, data.UnitsAmount, - )) - w.Write(e) + writeJSON(w, http.StatusOK, statistics.GetChartData(data.Host, data.Service, data.Unit, data.UnitsAmount)) } func (h *RouteController) GetHosts(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { + if !guard(w, req, authUser, "") { return } @@ -519,7 +510,7 @@ func (h *RouteController) GetHosts(w http.ResponseWriter, req *http.Request) { allContainers := []map[string]interface{}{} for _, container := range containers { isFavorite, _ := vars.FavsDB.Has(favouriteKey(viewer, host.Name(), container.Name()), nil) - if util.Contains(container.Name(), activeContainers) || util.Contains(container.Name(), vars.AgentContainers(host.Name())) { + if slices.Contains(activeContainers, container.Name()) || slices.Contains(vars.AgentContainers(host.Name()), container.Name()) { allContainers = append(allContainers, map[string]interface{}{"serviceName": container.Name(), "isDisabled": false, "isFavorite": isFavorite}) } else { allContainers = append(allContainers, map[string]interface{}{"serviceName": container.Name(), "isDisabled": true, "isFavorite": isFavorite}) @@ -528,13 +519,11 @@ func (h *RouteController) GetHosts(w http.ResponseWriter, req *http.Request) { to_return = append(to_return, HostsList{Host: host.Name(), Services: allContainers}) } - w.Header().Add("Content-Type", "application/json") - e, _ := json.Marshal(to_return) - w.Write(e) + writeJSON(w, http.StatusOK, to_return) } func (h *RouteController) GetSizeByAll(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { + if !guard(w, req, authUser, "") { return } @@ -544,13 +533,12 @@ func (h *RouteController) GetSizeByAll(w http.ResponseWriter, req *http.Request) if totalSize < 0.1 && totalSize != 0.0 { totalSize = 0.1 } - w.Header().Add("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"sizeMiB": fmt.Sprintf("%.1f", totalSize)}) // MiB + writeJSON(w, http.StatusOK, map[string]interface{}{"sizeMiB": fmt.Sprintf("%.1f", totalSize)}) // MiB } // TODO need to return 0.0 when there is no logs for container in db func (h *RouteController) GetSizeByService(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { + if !guard(w, req, authUser, "") { return } @@ -563,17 +551,16 @@ func (h *RouteController) GetSizeByService(w http.ResponseWriter, req *http.Requ if params.Get("host") == "" { panic("Host is not mentioned!") } - w.Header().Add("Content-Type", "application/json") size := util.GetDirSize(params.Get("host"), params.Get("service")) if size < 0.1 && size != 0.0 { size = 0.1 } - json.NewEncoder(w).Encode(map[string]interface{}{"sizeMiB": fmt.Sprintf("%.1f", size)}) // MiB + writeJSON(w, http.StatusOK, map[string]interface{}{"sizeMiB": fmt.Sprintf("%.1f", size)}) // MiB } func (h *RouteController) GetDockerSize(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { + if !guard(w, req, authUser, "") { return } @@ -584,11 +571,9 @@ func (h *RouteController) GetDockerSize(w http.ResponseWriter, req *http.Request } if params.Get("host") == "" { - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"error": "Host is not mentioned!"}) + fail(w, http.StatusBadRequest, "Host is not mentioned!") return } - w.Header().Add("Content-Type", "application/json") // A container with no docker json log has size 0, not a nil dereference. var size float64 @@ -602,11 +587,11 @@ func (h *RouteController) GetDockerSize(w http.ResponseWriter, req *http.Request if size < 0.1 && size != 0.0 { size = 0.1 } - json.NewEncoder(w).Encode(map[string]interface{}{"sizeMiB": fmt.Sprintf("%.1f", size)}) // MiB + writeJSON(w, http.StatusOK, map[string]interface{}{"sizeMiB": fmt.Sprintf("%.1f", size)}) // MiB } func (h *RouteController) GetStats(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { + if !guard(w, req, authUser, "") { return } @@ -619,12 +604,11 @@ func (h *RouteController) GetStats(w http.ResponseWriter, req *http.Request) { if !decodeBody(w, req, &data) { return } - w.Header().Add("Content-Type", "application/json") - json.NewEncoder(w).Encode(statistics.GetStatisticsByService(data.Host, data.Service, data.Value)) + writeJSON(w, http.StatusOK, statistics.GetStatisticsByService(data.Host, data.Service, data.Value)) } func (h *RouteController) GetStorageData(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { + if !guard(w, req, authUser, "") { return } @@ -635,14 +619,13 @@ func (h *RouteController) GetStorageData(w http.ResponseWriter, req *http.Reques if !decodeBody(w, req, &data) { return } - w.Header().Add("Content-Type", "application/json") // TODO make for different hosts if data.Host != util.GetHost() { - json.NewEncoder(w).Encode(map[string]string{"error": "For now working only for main host.\nAsked host: " + data.Host + "\nIt's ok to see this message, all works fine."}) + fail(w, http.StatusOK, "For now working only for main host.\nAsked host: "+data.Host+"\nIt's ok to see this message, all works fine.") return } - json.NewEncoder(w).Encode(util.GetStorageData()) + writeJSON(w, http.StatusOK, util.GetStorageData()) } // Favourites are per user, like the user settings stored next to them. @@ -659,7 +642,7 @@ func statusFilter(params url.Values) *string { } func (h *RouteController) GetPrevLogs(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { + if !guard(w, req, authUser, "") { return } @@ -671,19 +654,18 @@ func (h *RouteController) GetPrevLogs(w http.ResponseWriter, req *http.Request) } if params.Get("startWith") == "" { - json.NewEncoder(w).Encode(map[string]interface{}{"error": "Need to specify \"startWith\"!"}) + writeJSON(w, http.StatusOK, map[string]interface{}{"error": "Need to specify \"startWith\"!"}) return } - w.Header().Add("Content-Type", "application/json") if params.Get("host") == "" { panic("Host is not mentioned!") } - json.NewEncoder(w).Encode(containerdb.GetLogs(true, false, params.Get("host"), params.Get("id"), params.Get("search"), limit, params.Get("startWith"), caseSensetive, statusFilter(params))) + writeJSON(w, http.StatusOK, containerdb.GetLogs(true, false, params.Get("host"), params.Get("id"), params.Get("search"), limit, params.Get("startWith"), caseSensetive, statusFilter(params))) } func (h *RouteController) GetLogs(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { + if !guard(w, req, authUser, "") { return } @@ -693,34 +675,32 @@ func (h *RouteController) GetLogs(w http.ResponseWriter, req *http.Request) { if err != nil { caseSensetive = false } - w.Header().Add("Content-Type", "application/json") if params.Get("host") == "" { panic("Host is not mentioned!") } - json.NewEncoder(w).Encode(containerdb.GetLogs( + writeJSON(w, http.StatusOK, containerdb.GetLogs( false, false, params.Get("host"), params.Get("id"), params.Get("search"), limit, params.Get("startWith"), caseSensetive, statusFilter(params), )) } func (h *RouteController) GetLogWithPrev(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { + if !guard(w, req, authUser, "") { return } params := req.URL.Query() limit, _ := strconv.Atoi(params.Get("limit")) - w.Header().Add("Content-Type", "application/json") if params.Get("host") == "" { panic("Host is not mentioned!") } - json.NewEncoder(w).Encode(containerdb.GetLogs(false, true, params.Get("host"), params.Get("id"), "", limit, params.Get("startWith"), false, statusFilter(params))) + writeJSON(w, http.StatusOK, containerdb.GetLogs(false, true, params.Get("host"), params.Get("id"), "", limit, params.Get("startWith"), false, statusFilter(params))) } // TODO return {"error": "Invalid host!"} when host is not exists func (h *RouteController) GetLogsStream(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { + if !guard(w, req, authUser, "") { return } @@ -755,11 +735,11 @@ func (h *RouteController) GetLogsStream(w http.ResponseWriter, req *http.Request } func (h *RouteController) Login(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) { - json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) + enableCors(w) + if req.Method == http.MethodOptions { + ok(w) return } - if req.Method != "POST" { w.WriteHeader(http.StatusNotFound) return @@ -777,9 +757,7 @@ func (h *RouteController) Login(w http.ResponseWriter, req *http.Request) { pairKey := "pair:" + loginKey(addr, loginData.Login) if !loginLimiter.allow(ipKey, pairKey) { vars.LoginBlocked.Add(1) - w.Header().Add("Content-Type", "application/json") - w.WriteHeader(http.StatusTooManyRequests) - json.NewEncoder(w).Encode(map[string]string{"error": "Too many failed login attempts. Try again later."}) + fail(w, http.StatusTooManyRequests, "Too many failed login attempts. Try again later.") return } @@ -788,7 +766,7 @@ func (h *RouteController) Login(w http.ResponseWriter, req *http.Request) { // Once per request, not once per key: fail() takes two. vars.LoginFailures.Add(1) loginLimiter.fail(ipKey, pairKey) - json.NewEncoder(w).Encode(map[string]string{"error": "Wrong login or password!"}) + fail(w, http.StatusOK, "Wrong login or password!") return } loginLimiter.succeed(ipKey, pairKey) @@ -803,12 +781,11 @@ func (h *RouteController) Login(w http.ResponseWriter, req *http.Request) { SameSite: http.SameSiteLaxMode, Path: "/", }) - w.Header().Add("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) + ok(w) } func (h *RouteController) Logout(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { + if !guard(w, req, authUser, "") { return } http.SetCookie(w, &http.Cookie{ @@ -821,17 +798,11 @@ func (h *RouteController) Logout(w http.ResponseWriter, req *http.Request) { SameSite: http.SameSiteLaxMode, Path: "/", }) - w.Header().Add("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) + ok(w) } func (h *RouteController) CreateUser(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyAdminUser(&w, req) { - return - } - - if req.Method != "POST" { - w.WriteHeader(http.StatusNotFound) + if !guard(w, req, authAdmin, "POST") { return } @@ -842,25 +813,23 @@ func (h *RouteController) CreateUser(w http.ResponseWriter, req *http.Request) { err := userdb.CreateUser(loginData.Login, loginData.Password) if err == nil { - json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) + ok(w) return } - w.Header().Add("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + fail(w, http.StatusOK, err.Error()) } func (h *RouteController) GetUsers(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyAdminUser(&w, req) { + if !guard(w, req, authAdmin, "") { return } users := userdb.GetUsers() - w.Header().Add("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"users": users, "error": nil}) + writeJSON(w, http.StatusOK, map[string]interface{}{"users": users, "error": nil}) } func (h *RouteController) UpdateUserSettings(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { + if !guard(w, req, authUser, "") { return } @@ -869,27 +838,24 @@ func (h *RouteController) UpdateUserSettings(w http.ResponseWriter, req *http.Re return } username, _ := util.GetUserFromJWT(*req) - w.Header().Add("Content-Type", "application/json") if err := userdb.UpdateUserSettings(username, settings); err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + fail(w, http.StatusInternalServerError, err.Error()) return } - json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) + ok(w) } func (h *RouteController) GetUserSettings(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyUser(&w, req) { + if !guard(w, req, authUser, "") { return } username, _ := util.GetUserFromJWT(*req) - w.Header().Add("Content-Type", "application/json") - json.NewEncoder(w).Encode(userdb.GetUserSettings(username)) + writeJSON(w, http.StatusOK, userdb.GetUserSettings(username)) } func (h *RouteController) EditUser(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyAdminUser(&w, req) { + if !guard(w, req, authAdmin, "") { return } @@ -899,33 +865,30 @@ func (h *RouteController) EditUser(w http.ResponseWriter, req *http.Request) { } if loginData.Login == os.Getenv("ADMIN_USERNAME") { - w.Header().Add("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"error": "Can't edit admin. Use env variables to change admin username and password"}) + fail(w, http.StatusOK, "Can't edit admin. Use env variables to change admin username and password") return } if !userdb.IsUserExists(loginData.Login) { - json.NewEncoder(w).Encode(map[string]string{"error": "No such user"}) + fail(w, http.StatusOK, "No such user") return } - w.Header().Add("Content-Type", "application/json") - if loginData.Password == "" { - json.NewEncoder(w).Encode(map[string]string{"error": "Password can not be empty"}) + fail(w, http.StatusOK, "Password can not be empty") return } if err := userdb.EditUser(loginData.Login, loginData.Password); err != nil { - json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + fail(w, http.StatusOK, err.Error()) return } - json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) + ok(w) } func (h *RouteController) DeleteContainerLogs(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyAdminUser(&w, req) { + if !guard(w, req, authAdmin, "") { return } @@ -938,12 +901,11 @@ func (h *RouteController) DeleteContainerLogs(w http.ResponseWriter, req *http.R } go containerdb.DeleteContainer(containerItem.Host, containerItem.Service, false) - w.Header().Add("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) + ok(w) } func (h *RouteController) DeleteDockerLogs(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyAdminUser(&w, req) { + if !guard(w, req, authAdmin, "") { return } @@ -955,8 +917,7 @@ func (h *RouteController) DeleteDockerLogs(w http.ResponseWriter, req *http.Requ return } - w.Header().Add("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"error": util.DeleteDockerLogs(logItem.Host, logItem.Service)}) + writeJSON(w, http.StatusOK, map[string]interface{}{"error": util.DeleteDockerLogs(logItem.Host, logItem.Service)}) } func (h *RouteController) AskForDelete(w http.ResponseWriter, req *http.Request) { @@ -975,12 +936,11 @@ func (h *RouteController) AskForDelete(w http.ResponseWriter, req *http.Request) to_delete := vars.TakeQueuedDeletes(logItem.Hostname) - w.Header().Add("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"Services": to_delete}) + writeJSON(w, http.StatusOK, map[string]interface{}{"Services": to_delete}) } func (h *RouteController) DeleteContainer(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyAdminUser(&w, req) { + if !guard(w, req, authAdmin, "") { return } @@ -997,23 +957,17 @@ func (h *RouteController) DeleteContainer(w http.ResponseWriter, req *http.Reque dockerImage, _ := h.DockerService.GetContainerImageNameByContainerID(req.Context(), dockerContainerID) if strings.Contains(dockerImage, "devforth/onlogs") { w.WriteHeader(http.StatusForbidden) - json.NewEncoder(w).Encode(map[string]interface{}{"error": "Can't delete logs of OnLogs container!"}) + writeJSON(w, http.StatusOK, map[string]interface{}{"error": "Can't delete logs of OnLogs container!"}) return } } go containerdb.DeleteContainer(logItem.Host, logItem.Service, true) - w.Header().Add("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) + ok(w) } func (h *RouteController) DeleteUser(w http.ResponseWriter, req *http.Request) { - if verifyRequest(&w, req) || !verifyAdminUser(&w, req) { - return - } - - if req.Method != "POST" { - w.WriteHeader(http.StatusNotFound) + if !guard(w, req, authAdmin, "POST") { return } @@ -1024,16 +978,14 @@ func (h *RouteController) DeleteUser(w http.ResponseWriter, req *http.Request) { return } if loginData.Login == os.Getenv("ADMIN_USERNAME") { - w.Header().Add("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"error": "Can't delete admin"}) + fail(w, http.StatusOK, "Can't delete admin") return } err := userdb.DeleteUser(loginData.Login) if err != nil { - json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + fail(w, http.StatusOK, err.Error()) return } - w.Header().Add("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"error": nil}) + ok(w) } diff --git a/application/backend/app/routes/routes_test.go b/application/backend/app/routes/routes_test.go index 9785877..be6f23a 100644 --- a/application/backend/app/routes/routes_test.go +++ b/application/backend/app/routes/routes_test.go @@ -21,6 +21,8 @@ import ( "github.com/syndtr/goleveldb/leveldb" ) +var testCtrl *RouteController + func TestMain(m *testing.M) { if os.Getenv("JWT_SECRET") == "" { os.Setenv("JWT_SECRET", "routes-package-test-signing-key") @@ -32,37 +34,26 @@ func TestMain(m *testing.M) { userdb.CreateUser("testuser", "testuser") userdb.CreateUser("viewer", "viewer-password") userdb.CreateUser("someuser", "someuser-password") - os.Exit(m.Run()) -} -func initTestConfig() *RouteController { cli, _ := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) - defer cli.Close() - - dockerService := &docker.DockerService{ - Client: cli, - } - - daemonService := &daemon.DaemonService{ - DockerClient: dockerService, - } - - // Initialize the "Controller" with its dependencies - routerCtrl := &RouteController{ + dockerService := &docker.DockerService{Client: cli} + testCtrl = &RouteController{ DockerService: dockerService, - DaemonService: daemonService, + DaemonService: daemon.NewDaemonService(dockerService), } - return routerCtrl + + code := m.Run() + cli.Close() + os.Exit(code) } func TestFrontend(t *testing.T) { - ctrl := initTestConfig() os.Mkdir("dist", 0700) os.WriteFile("dist/index.html", []byte("text"), 0700) req1, _ := http.NewRequest("GET", "/frontend", nil) rr1 := httptest.NewRecorder() - handler1 := http.HandlerFunc(ctrl.Frontend) + handler1 := http.HandlerFunc(testCtrl.Frontend) handler1.ServeHTTP(rr1, req1) body1, _ := io.ReadAll(rr1.Result().Body) if string(body1) != "text" { @@ -71,7 +62,7 @@ func TestFrontend(t *testing.T) { req2, _ := http.NewRequest("GET", "/fasf", nil) rr2 := httptest.NewRecorder() - handler2 := http.HandlerFunc(ctrl.Frontend) + handler2 := http.HandlerFunc(testCtrl.Frontend) handler2.ServeHTTP(rr2, req2) body2, _ := io.ReadAll(rr2.Result().Body) if string(body2) != "text" { @@ -80,27 +71,11 @@ func TestFrontend(t *testing.T) { } func TestCheckCookie(t *testing.T) { - ctrl := initTestConfig() - - req1, _ := http.NewRequest("GET", "/frontend", nil) - rr1 := httptest.NewRecorder() - handler1 := http.HandlerFunc(ctrl.CheckCookie) - handler1.ServeHTTP(rr1, req1) - if rr1.Result().StatusCode != 401 { - t.Error("Should be unauthorized!") + if status, _ := call(t, testCtrl.CheckCookie, authedRequest(t, "", "GET", "/", nil)); status != 401 { + t.Errorf("a request with no cookie returned %d, want 401", status) } - - req2, _ := http.NewRequest("GET", "/", nil) - req2.AddCookie(&http.Cookie{ - Name: "onlogs-cookie", - Value: util.CreateJWT("testuser"), - }) - userdb.CreateUser("testuser", "testuser") - rr2 := httptest.NewRecorder() - handler2 := http.HandlerFunc(ctrl.CheckCookie) - handler2.ServeHTTP(rr2, req2) - if rr2.Result().StatusCode != 200 { - t.Error("Should be unauthorized!") + if status, _ := call(t, testCtrl.CheckCookie, authedRequest(t, "testuser", "GET", "/", nil)); status != 200 { + t.Errorf("a signed request returned %d, want 200", status) } } @@ -109,7 +84,6 @@ func TestGetHosts(t *testing.T) { if err != nil { os.Setenv("DOCKER_HOST", "unix:///var/run/docker.sock") } - ctrl := initTestConfig() os.RemoveAll("leveldb/hosts") os.MkdirAll("leveldb/hosts/Test1/containers/containerTest1", 0700) @@ -125,10 +99,8 @@ func TestGetHosts(t *testing.T) { Value: util.CreateJWT("testuser"), }) - userdb.CreateUser("testuser", "testuser") - rr1 := httptest.NewRecorder() - handler1 := http.HandlerFunc(ctrl.GetHosts) + handler1 := http.HandlerFunc(testCtrl.GetHosts) handler1.ServeHTTP(rr1, req1) b, _ := io.ReadAll(rr1.Result().Body) @@ -185,87 +157,54 @@ func TestGetHosts(t *testing.T) { } } -func TestSizeByAll(t *testing.T) { - ctrl := initTestConfig() - req1, _ := http.NewRequest("GET", "/", nil) - req1.AddCookie(&http.Cookie{ - Name: "onlogs-cookie", - Value: util.CreateJWT("testuser"), - }) - userdb.CreateUser("testuser", "testuser") - rr1 := httptest.NewRecorder() - handler1 := http.HandlerFunc(ctrl.GetSizeByAll) - handler1.ServeHTTP(rr1, req1) - b, _ := io.ReadAll(rr1.Result().Body) - if !strings.Contains(string(b), "\"0.0\"") { - t.Error("Wrong size: ", string(b)) +func TestSizeEndpointsReportAnEmptyStore(t *testing.T) { + cases := []struct { + name string + handler http.HandlerFunc + target string + }{ + {"sizeByAll", testCtrl.GetSizeByAll, "/api/v1/getSizeByAll"}, + {"sizeByService", testCtrl.GetSizeByService, "/api/v1/getSizeByService?service=containerTest1&host=Test1"}, } -} -func TestSizeByService(t *testing.T) { - ctrl := initTestConfig() - req1, _ := http.NewRequest("GET", "/getSizeByService?service=containerTest1&host=Test1", nil) - req1.AddCookie(&http.Cookie{ - Name: "onlogs-cookie", - Value: util.CreateJWT("testuser"), - }) - userdb.CreateUser("testuser", "testuser") - rr1 := httptest.NewRecorder() - handler1 := http.HandlerFunc(ctrl.GetSizeByAll) - handler1.ServeHTTP(rr1, req1) - b, _ := io.ReadAll(rr1.Result().Body) - if !strings.Contains(string(b), "\"0.0\"") { - t.Error("Wrong size: ", string(b)) + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, body := call(t, c.handler, authedRequest(t, "testuser", "GET", c.target, nil)) + if !strings.Contains(string(body), "\"0.0\"") { + t.Errorf("%s reported %s, want 0.0", c.name, body) + } + }) } } func TestLogin(t *testing.T) { - ctrl := initTestConfig() - postBody, _ := json.Marshal(map[string]string{ - "Login": "testuser", - "Password": "testsuser", - }) - req1, _ := http.NewRequest("POST", "/", bytes.NewBuffer(postBody)) - userdb.CreateUser("testuser", "testuser") - - rr1 := httptest.NewRecorder() - handler1 := http.HandlerFunc(ctrl.Login) - handler1.ServeHTTP(rr1, req1) - b, _ := io.ReadAll(rr1.Result().Body) - if !strings.Contains(string(b), "Wrong") { - t.Error("Password must be wrong!") + _, wrong := call(t, testCtrl.Login, + authedRequest(t, "", "POST", "/", map[string]string{"Login": "testuser", "Password": "testsuser"})) + if !strings.Contains(string(wrong), "Wrong") { + t.Errorf("a bad password was accepted: %s", wrong) } - postBody2, _ := json.Marshal(map[string]string{ - "Login": "testuser", - "Password": "testuser", - }) - req2, _ := http.NewRequest("POST", "/", bytes.NewBuffer(postBody2)) - rr2 := httptest.NewRecorder() - handler2 := http.HandlerFunc(ctrl.Login) - handler2.ServeHTTP(rr2, req2) - b2, _ := io.ReadAll(rr2.Result().Body) - if !strings.Contains(string(b2), "null") { - t.Error("Password must be wrong!") + _, right := call(t, testCtrl.Login, + authedRequest(t, "", "POST", "/", map[string]string{"Login": "testuser", "Password": "testuser"})) + if !strings.Contains(string(right), "null") { + t.Errorf("the correct password was rejected: %s", right) } } func TestLogout(t *testing.T) { - ctrl := initTestConfig() postBody, _ := json.Marshal(map[string]string{ "Login": "testuser", "Password": "testuser", }) req1, _ := http.NewRequest("POST", "/", bytes.NewBuffer(postBody)) - userdb.CreateUser("testuser", "testuser") rr1 := httptest.NewRecorder() - handler1 := http.HandlerFunc(ctrl.Login) + handler1 := http.HandlerFunc(testCtrl.Login) handler1.ServeHTTP(rr1, req1) rr2 := httptest.NewRecorder() req1.AddCookie(rr1.Result().Cookies()[0]) - handler2 := http.HandlerFunc(ctrl.Logout) + handler2 := http.HandlerFunc(testCtrl.Logout) handler2.ServeHTTP(rr2, req1) if rr2.Result().Cookies()[0].Value != "toDelete" { @@ -274,15 +213,13 @@ func TestLogout(t *testing.T) { } func TestGetStats(t *testing.T) { - ctrl := initTestConfig() postBody1, _ := json.Marshal(map[string]string{ "Login": "testuser", "Password": "testuser", }) req1, _ := http.NewRequest("POST", "/", bytes.NewBuffer(postBody1)) - userdb.CreateUser("testuser", "testuser") rr1 := httptest.NewRecorder() - handler1 := http.HandlerFunc(ctrl.Login) + handler1 := http.HandlerFunc(testCtrl.Login) handler1.ServeHTTP(rr1, req1) rr2 := httptest.NewRecorder() @@ -301,7 +238,7 @@ func TestGetStats(t *testing.T) { }) req2, _ := http.NewRequest("POST", "/", bytes.NewBuffer(postBody2)) req2.AddCookie(rr1.Result().Cookies()[0]) - handler2 := http.HandlerFunc(ctrl.GetStats) + handler2 := http.HandlerFunc(testCtrl.GetStats) handler2.ServeHTTP(rr2, req2) b, _ := io.ReadAll(rr2.Result().Body) @@ -315,19 +252,16 @@ func TestGetStats(t *testing.T) { } func TestGetChartData(t *testing.T) { - ctrl := initTestConfig() postBody1, _ := json.Marshal(map[string]string{ "Login": "testuser", "Password": "testuser", }) req1, _ := http.NewRequest("POST", "/", bytes.NewBuffer(postBody1)) - userdb.CreateUser("testuser", "testuser") rr1 := httptest.NewRecorder() - handler1 := http.HandlerFunc(ctrl.Login) + handler1 := http.HandlerFunc(testCtrl.Login) handler1.ServeHTTP(rr1, req1) cur_db, _ := leveldb.OpenFile("leveldb/hosts/test/statistics", nil) - vars.Stat_Hosts_DBs["test"] = cur_db vars.Container_Stat_Counter["test/test"] = map[string]uint64{"error": 2, "debug": 1, "info": 3, "warn": 5, "meta": 0, "other": 4} vars.Stat_Containers_DBs["test/test"] = cur_db to_put, _ := json.Marshal(vars.Container_Stat_Counter["test/test"]) @@ -343,7 +277,7 @@ func TestGetChartData(t *testing.T) { }) req2, _ := http.NewRequest("POST", "/", bytes.NewBuffer(postBody2)) req2.AddCookie(rr1.Result().Cookies()[0]) - handler2 := http.HandlerFunc(ctrl.GetChartData) + handler2 := http.HandlerFunc(testCtrl.GetChartData) handler2.ServeHTTP(rr2, req2) res := map[string]map[string]int{} diff --git a/application/backend/app/routes/security_test.go b/application/backend/app/routes/security_test.go index 3bd5887..fd64a0d 100644 --- a/application/backend/app/routes/security_test.go +++ b/application/backend/app/routes/security_test.go @@ -22,7 +22,6 @@ import ( ) func TestFrontendDoesNotServeFilesOutsideDist(t *testing.T) { - ctrl := initTestConfig() os.MkdirAll("dist", 0o700) os.WriteFile("dist/index.html", []byte("text"), 0o600) @@ -37,7 +36,7 @@ func TestFrontendDoesNotServeFilesOutsideDist(t *testing.T) { } { req, _ := http.NewRequest("GET", target, nil) rr := httptest.NewRecorder() - http.HandlerFunc(ctrl.Frontend).ServeHTTP(rr, req) + http.HandlerFunc(testCtrl.Frontend).ServeHTTP(rr, req) body, _ := io.ReadAll(rr.Result().Body) if strings.Contains(string(body), "REAL-ONLOGS-JWT-SECRET") { t.Fatalf("arbitrary file read through %q: %s", target, string(body)) @@ -46,7 +45,6 @@ func TestFrontendDoesNotServeFilesOutsideDist(t *testing.T) { } func TestFrontendStripsPathPrefixOnlyAtTheFront(t *testing.T) { - ctrl := initTestConfig() t.Setenv("ONLOGS_PATH_PREFIX", "/logs") os.MkdirAll("dist/assets", 0o700) @@ -56,7 +54,7 @@ func TestFrontendStripsPathPrefixOnlyAtTheFront(t *testing.T) { req, _ := http.NewRequest("GET", "/logs/assets/logs-panel.js", nil) rr := httptest.NewRecorder() - http.HandlerFunc(ctrl.Frontend).ServeHTTP(rr, req) + http.HandlerFunc(testCtrl.Frontend).ServeHTTP(rr, req) body, _ := io.ReadAll(rr.Result().Body) if string(body) != "PANEL_ASSET" { t.Fatalf("expected the asset, got %q", string(body)) @@ -75,21 +73,18 @@ func adminOnlyRequest(t *testing.T, handler http.HandlerFunc, method, target str } func TestGetSecretIsAdminOnly(t *testing.T) { - ctrl := initTestConfig() - if code := adminOnlyRequest(t, ctrl.GetSecret, "GET", "/api/v1/getSecret"); code != http.StatusForbidden { + if code := adminOnlyRequest(t, testCtrl.GetSecret, "GET", "/api/v1/getSecret"); code != http.StatusForbidden { t.Fatalf("a non-admin account minted an agent token: status %d", code) } } func TestGetUsersIsAdminOnly(t *testing.T) { - ctrl := initTestConfig() - if code := adminOnlyRequest(t, ctrl.GetUsers, "GET", "/api/v1/getUsers"); code != http.StatusForbidden { + if code := adminOnlyRequest(t, testCtrl.GetUsers, "GET", "/api/v1/getUsers"); code != http.StatusForbidden { t.Fatalf("a non-admin account enumerated every username: status %d", code) } } func TestAddHostRejectsNamesThatLeaveTheTree(t *testing.T) { - ctrl := initTestConfig() token := db.CreateOnLogsToken() for _, payload := range []map[string]interface{}{ @@ -99,7 +94,7 @@ func TestAddHostRejectsNamesThatLeaveTheTree(t *testing.T) { body, _ := json.Marshal(payload) req, _ := http.NewRequest("POST", "/api/v1/addHost", bytes.NewBuffer(body)) rr := httptest.NewRecorder() - http.HandlerFunc(ctrl.AddHost).ServeHTTP(rr, req) + http.HandlerFunc(testCtrl.AddHost).ServeHTTP(rr, req) if code := rr.Result().StatusCode; code != http.StatusBadRequest { t.Errorf("addHost accepted %v: status %d", payload["Hostname"], code) @@ -126,7 +121,6 @@ func TestAddHostRejectsNamesThatLeaveTheTree(t *testing.T) { } func TestAddLogLineRejectsNamesThatLeaveTheTree(t *testing.T) { - ctrl := initTestConfig() token := db.CreateOnLogsToken() body, _ := json.Marshal(map[string]interface{}{ @@ -137,7 +131,7 @@ func TestAddLogLineRejectsNamesThatLeaveTheTree(t *testing.T) { }) req, _ := http.NewRequest("POST", "/api/v1/addLogLine", bytes.NewBuffer(body)) rr := httptest.NewRecorder() - http.HandlerFunc(ctrl.AddLogLine).ServeHTTP(rr, req) + http.HandlerFunc(testCtrl.AddLogLine).ServeHTTP(rr, req) if code := rr.Result().StatusCode; code != http.StatusBadRequest { t.Errorf("addLogLine accepted a traversing host: status %d", code) @@ -157,17 +151,16 @@ func oversizedJSONBody() []byte { } func TestHandlersRejectOversizedRequestBodies(t *testing.T) { - ctrl := initTestConfig() os.Setenv("ADMIN_USERNAME", "admin") cases := []struct { name string handler http.HandlerFunc }{ - {"updateUserSettings", ctrl.UpdateUserSettings}, - {"login", ctrl.Login}, - {"changeFavorite", ctrl.ChangeFavourite}, - {"addLogLine", ctrl.AddLogLine}, + {"updateUserSettings", testCtrl.UpdateUserSettings}, + {"login", testCtrl.Login}, + {"changeFavorite", testCtrl.ChangeFavourite}, + {"addLogLine", testCtrl.AddLogLine}, } for _, c := range cases { @@ -183,13 +176,12 @@ func TestHandlersRejectOversizedRequestBodies(t *testing.T) { } func TestLoginCookieIsHttpOnlyAndUsesARelativeMaxAge(t *testing.T) { - ctrl := initTestConfig() userdb.CreateUser("cookieuser", "cookiepass") body, _ := json.Marshal(map[string]string{"Login": "cookieuser", "Password": "cookiepass"}) req, _ := http.NewRequest("POST", "/api/v1/login", bytes.NewBuffer(body)) rr := httptest.NewRecorder() - http.HandlerFunc(ctrl.Login).ServeHTTP(rr, req) + http.HandlerFunc(testCtrl.Login).ServeHTTP(rr, req) cookies := rr.Result().Cookies() if len(cookies) == 0 { @@ -210,14 +202,13 @@ func TestLoginCookieIsHttpOnlyAndUsesARelativeMaxAge(t *testing.T) { } func TestLoginCookieIsSecureOverTLS(t *testing.T) { - ctrl := initTestConfig() userdb.CreateUser("cookieuser", "cookiepass") body, _ := json.Marshal(map[string]string{"Login": "cookieuser", "Password": "cookiepass"}) req, _ := http.NewRequest("POST", "https://onlogs.example/api/v1/login", bytes.NewBuffer(body)) req.TLS = &tls.ConnectionState{} rr := httptest.NewRecorder() - http.HandlerFunc(ctrl.Login).ServeHTTP(rr, req) + http.HandlerFunc(testCtrl.Login).ServeHTTP(rr, req) cookies := rr.Result().Cookies() if len(cookies) == 0 { @@ -229,7 +220,6 @@ func TestLoginCookieIsSecureOverTLS(t *testing.T) { } func TestGetLogsStreamRejectsAForeignOrigin(t *testing.T) { - ctrl := initTestConfig() req, _ := http.NewRequest("GET", "/api/v1/getLogsStream?host="+util.GetHost()+"&id=somecontainer", nil) req.Host = "onlogs.example" @@ -241,7 +231,7 @@ func TestGetLogsStreamRejectsAForeignOrigin(t *testing.T) { req.AddCookie(&http.Cookie{Name: "onlogs-cookie", Value: util.CreateJWT("admin")}) rr := httptest.NewRecorder() - http.HandlerFunc(ctrl.GetLogsStream).ServeHTTP(rr, req) + http.HandlerFunc(testCtrl.GetLogsStream).ServeHTTP(rr, req) if code := rr.Result().StatusCode; code != http.StatusForbidden { t.Errorf("a cross-origin websocket handshake was not rejected: status %d", code) @@ -252,7 +242,6 @@ func TestGetLogsStreamRejectsAForeignOrigin(t *testing.T) { } func TestLoginRateLimitsRepeatedFailures(t *testing.T) { - ctrl := initTestConfig() userdb.CreateUser("ratelimited", "correct-horse") post := func(password string) int { @@ -260,7 +249,7 @@ func TestLoginRateLimitsRepeatedFailures(t *testing.T) { req, _ := http.NewRequest("POST", "/api/v1/login", bytes.NewBuffer(body)) req.RemoteAddr = "203.0.113.9:34567" rr := httptest.NewRecorder() - http.HandlerFunc(ctrl.Login).ServeHTTP(rr, req) + http.HandlerFunc(testCtrl.Login).ServeHTTP(rr, req) return rr.Result().StatusCode } @@ -274,7 +263,6 @@ func TestLoginRateLimitsRepeatedFailures(t *testing.T) { } func TestAddLogLineDoesNotSpawnAWorkerPerLogLine(t *testing.T) { - ctrl := initTestConfig() token := db.CreateOnLogsToken() before := statistics.WorkerCount() t.Cleanup(func() { statistics.StopWorker("statsprobehost", "statsprobecontainer") }) @@ -289,7 +277,7 @@ func TestAddLogLineDoesNotSpawnAWorkerPerLogLine(t *testing.T) { }) req, _ := http.NewRequest("POST", "/api/v1/addLogLine", bytes.NewBuffer(body)) rr := httptest.NewRecorder() - http.HandlerFunc(ctrl.AddLogLine).ServeHTTP(rr, req) + http.HandlerFunc(testCtrl.AddLogLine).ServeHTTP(rr, req) if code := rr.Result().StatusCode; code != http.StatusOK { t.Fatalf("ingestion failed: status %d", code) } @@ -312,3 +300,48 @@ func TestAddLogLineDoesNotSpawnAWorkerPerLogLine(t *testing.T) { t.Fatalf("a further 25 log lines started %d more goroutines; the leak scales with ingestion and each worker zeroes the live counter", growth) } } + +// The Content-Type was previously set per handler; a helper now owns it, so one +// endpoint of each shape has to prove the header survives. +func TestJSONEndpointsSetTheirContentType(t *testing.T) { + os.Setenv("ADMIN_USERNAME", "admin") + + cases := []struct { + name string + handler http.HandlerFunc + user string + }{ + {"checkCookie", testCtrl.CheckCookie, "testuser"}, + {"getHosts", testCtrl.GetHosts, "testuser"}, + {"getUsers", testCtrl.GetUsers, "admin"}, + {"getSizeByAll", testCtrl.GetSizeByAll, "testuser"}, + {"getGroups", testCtrl.GetGroups, "testuser"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req, _ := http.NewRequest("GET", "/", nil) + req.AddCookie(&http.Cookie{Name: "onlogs-cookie", Value: util.CreateJWT(c.user)}) + rr := httptest.NewRecorder() + c.handler.ServeHTTP(rr, req) + + if got := rr.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/json") { + t.Errorf("%s replied with Content-Type %q, want application/json", c.name, got) + } + }) + } +} + +func TestRejectedRequestsStillSetContentType(t *testing.T) { + + req, _ := http.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + testCtrl.CheckCookie(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rr.Code) + } + if got := rr.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/json") { + t.Errorf("a 401 replied with Content-Type %q, want application/json", got) + } +} diff --git a/application/backend/app/statistics/keys_test.go b/application/backend/app/statistics/keys_test.go index 695e5df..197ed4d 100644 --- a/application/backend/app/statistics/keys_test.go +++ b/application/backend/app/statistics/keys_test.go @@ -74,8 +74,8 @@ func TestSaveStatsAlwaysWritesAParseableKey(t *testing.T) { } fullLogKey := "2026-02-10T12:44:02.123456789Z +1786097928311912188-9" - saveStats(db, emptyStats(), fullLogKey) - saveStats(db, emptyStats(), "not a timestamp at all") + saveStats(db, containerdb.NewStatCounter(), fullLogKey) + saveStats(db, containerdb.NewStatCounter(), "not a timestamp at all") iter := db.NewIterator(nil, nil) defer iter.Release() diff --git a/application/backend/app/statistics/race_test.go b/application/backend/app/statistics/race_test.go index 4abee7e..5617bba 100644 --- a/application/backend/app/statistics/race_test.go +++ b/application/backend/app/statistics/race_test.go @@ -4,6 +4,7 @@ import ( "os" "testing" + "github.com/devforth/OnLogs/app/containerdb" "github.com/devforth/OnLogs/app/vars" ) @@ -30,9 +31,7 @@ func TestGetStatisticsByServiceDoesNotHandOutTheLiveCounter(t *testing.T) { location := host + "/" + service _ = os.RemoveAll("leveldb/hosts/" + host) - vars.Mutex.Lock() - vars.Container_Stat_Counter[location] = map[string]uint64{"error": 0, "debug": 0, "info": 0, "warn": 0, "meta": 0, "other": 0} - vars.Mutex.Unlock() + seedCounter(location, containerdb.NewStatCounter()) stop := make(chan struct{}) done := make(chan struct{}) @@ -56,9 +55,7 @@ func TestGetChartDataDoesNotHandOutTheLiveCounter(t *testing.T) { location := host + "/" + service _ = os.RemoveAll("leveldb/hosts/" + host) - vars.Mutex.Lock() - vars.Container_Stat_Counter[location] = map[string]uint64{"error": 0, "debug": 0, "info": 0, "warn": 0, "meta": 0, "other": 0} - vars.Mutex.Unlock() + seedCounter(location, containerdb.NewStatCounter()) stop := make(chan struct{}) done := make(chan struct{}) diff --git a/application/backend/app/statistics/registry_test.go b/application/backend/app/statistics/registry_test.go new file mode 100644 index 0000000..d777eb6 --- /dev/null +++ b/application/backend/app/statistics/registry_test.go @@ -0,0 +1,85 @@ +package statistics + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/devforth/OnLogs/app/vars" +) + +// Workers zero the live counter on their own schedule, so seeding it from a +// test has to take the same lock they do. +func seedCounter(location string, stats map[string]uint64) { + vars.Mutex.Lock() + defer vars.Mutex.Unlock() + vars.Container_Stat_Counter[location] = stats +} + +// One worker per host/container, shared by the docker streamer and the agent +// ingestion route. +func TestEnsureWorkerRejectsDuplicates(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + baseline := WorkerCount() + t.Cleanup(func() { StopWorker("host", "container") }) + + first := EnsureWorker(ctx, "host", "container") + second := EnsureWorker(ctx, "host", "container") + + if !first { + t.Fatal("first registration must succeed") + } + if second { + t.Fatal("duplicate registration must be rejected") + } + if got := WorkerCount(); got != baseline+1 { + t.Fatalf("expected exactly one new worker, got %d (baseline %d)", got, baseline) + } +} + +func TestWorkerChurnDoesNotLeak(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + baseline := WorkerCount() + host := "churn-host" + for i := 0; i < 300; i++ { + container := fmt.Sprintf("ephemeral-%d", i) + EnsureWorker(ctx, host, container) + StopWorker(host, container) + } + + if got := WorkerCount(); got != baseline { + t.Fatalf("expected %d workers after churn, got %d", baseline, got) + } +} + +func TestWorkerCreatesItsCounterAndDatabase(t *testing.T) { + const location = "Test/TestContainer" + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go RunStatisticForContainerWithContext(ctx, "Test", "TestContainer") + + // Polled, not slept: under -race the worker takes far longer to reach its + // first flush than any fixed delay worth hard-coding. + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + vars.DBMutex.RLock() + statsDB := vars.Stat_Containers_DBs[location] + vars.DBMutex.RUnlock() + + vars.Mutex.Lock() + _, counted := vars.Container_Stat_Counter[location] + vars.Mutex.Unlock() + + if statsDB != nil && counted { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("the worker never created its counter and statistics database") +} diff --git a/application/backend/app/statistics/statistics.go b/application/backend/app/statistics/statistics.go index 6a8b7ca..99a7649 100644 --- a/application/backend/app/statistics/statistics.go +++ b/application/backend/app/statistics/statistics.go @@ -12,16 +12,12 @@ import ( "github.com/syndtr/goleveldb/leveldb" ) -func emptyStats() map[string]uint64 { - return map[string]uint64{"error": 0, "debug": 0, "info": 0, "warn": 0, "meta": 0, "other": 0} -} - // The caller must never receive the live map: it is written on every log line. func snapshotCounter(location string) map[string]uint64 { vars.Mutex.Lock() defer vars.Mutex.Unlock() - snapshot := emptyStats() + snapshot := containerdb.NewStatCounter() for key, value := range vars.Container_Stat_Counter[location] { snapshot[key] = value } @@ -43,12 +39,12 @@ func restartStats(host string, container string) { // Anchored to the newest line actually seen, never to the clock: the // clock is ahead of lines docker has not replayed yet, and the forward // scan would then seek straight past them. - calc_stat, newest := collectLogsBackward(host, container, "") + calc_stat, newest := collectLogs(host, container, "", false) if newest != "" { saveStats(current_db, calc_stat, newest) } } else { - calc_stat, new_datetime := collectLogsForward(host, container, last_stat_time) + calc_stat, new_datetime := collectLogs(host, container, last_stat_time, true) // Nothing new: saving would rewrite the previous interval's record with // an empty one and move the cursor off the log timeline onto the clock. if last_stat_time != new_datetime { @@ -70,60 +66,37 @@ func getLastStatTime(db *leveldb.DB) string { return string(iter.Key()) } -// Also reports the newest log key it saw, which is where the cursor belongs. -func collectLogsBackward(host, container, until string) (map[string]uint64, string) { - calc_stat := map[string]uint64{"error": 0, "debug": 0, "info": 0, "warn": 0, "meta": 0, "other": 0} +// A forward scan returns the cursor it finished on; a backward scan returns the +// newest key it saw, which is where a first cursor belongs. +func collectLogs(host, container, cursor string, forward bool) (map[string]uint64, string) { + stats := containerdb.NewStatCounter() newest := "" for { - raw_logs := containerdb.GetLogs(false, false, host, container, "", 1000, until, true, nil) - logs, ok := raw_logs["logs"].([][]string) + page := containerdb.GetLogs(forward, false, host, container, "", 1000, cursor, true, nil) + logs, ok := page["logs"].([][]string) if !ok || len(logs) == 0 { break } - // The scan walks newest to oldest, so the first row of the first page is - // the newest line there is. - if newest == "" { + if !forward && newest == "" { newest = logs[0][2] } for _, log := range logs { - status_key := containerdb.GetLogStatusKey(log[1]) - calc_stat[status_key]++ + stats[containerdb.GetLogStatusKey(log[1])]++ } - if raw_logs["is_end"].(bool) { + cursor = page["last_processed_key"].(string) + if page["is_end"].(bool) { break } - until = raw_logs["last_processed_key"].(string) } - return calc_stat, newest -} - -func collectLogsForward(host, container, since string) (map[string]uint64, string) { - calcStat := map[string]uint64{"error": 0, "debug": 0, "info": 0, "warn": 0, "meta": 0, "other": 0} - - for { - rawLogs := containerdb.GetLogs(true, false, host, container, "", 1000, since, true, nil) - logs, ok := rawLogs["logs"].([][]string) - if !ok || len(logs) == 0 { - break - } - - for _, log := range logs { - statusKey := containerdb.GetLogStatusKey(log[1]) - calcStat[statusKey]++ - } - - since = rawLogs["last_processed_key"].(string) - - if rawLogs["is_end"].(bool) { - break - } + if forward { + return stats, cursor } - return calcStat, since + return stats, newest } // Statistics are keyed by time and read back with time.Parse, so a full log key @@ -140,15 +113,13 @@ func saveStats(db *leveldb.DB, stats map[string]uint64, timestamp string) { func resetInMemoryStats(location string) { vars.Mutex.Lock() - vars.Container_Stat_Counter[location] = emptyStats() + vars.Container_Stat_Counter[location] = containerdb.NewStatCounter() vars.Mutex.Unlock() } func RunStatisticForContainerWithContext(ctx context.Context, host string, container string) { location := host + "/" + container - vars.Mutex.Lock() - vars.Container_Stat_Counter[location] = emptyStats() - vars.Mutex.Unlock() + resetInMemoryStats(location) defer restartStats(host, container) for { select { @@ -165,10 +136,6 @@ func RunStatisticForContainerWithContext(ctx context.Context, host string, conta } } -func RunStatisticForContainer(host string, container string) { - RunStatisticForContainerWithContext(context.Background(), host, container) -} - func GetStatisticsByService(host string, service string, value int) map[string]uint64 { location := host + "/" + service to_return := snapshotCounter(location) @@ -178,7 +145,6 @@ func GetStatisticsByService(host string, service string, value int) map[string]u } searchTo := time.Now().Add(-(time.Hour * time.Duration(value/2))).UTC() - var tmp_stats map[string]uint64 current_db := util.GetDBIfExists(host, service, "statistics") if current_db == nil { return to_return @@ -186,23 +152,20 @@ func GetStatisticsByService(host string, service string, value int) map[string]u iter := current_db.NewIterator(nil, nil) defer iter.Release() iter.Last() - hasPrev := true - result_map := map[string]uint64{"debug": to_return["debug"], "error": to_return["error"], "info": to_return["info"], "warn": to_return["warn"], "meta": to_return["meta"], "other": to_return["other"]} - for hasPrev { + + for hasPrev := true; hasPrev; hasPrev = iter.Prev() { tmp_time, _ := time.Parse(time.RFC3339Nano, string(iter.Key())) if searchTo.After(tmp_time) { break } - json.Unmarshal(iter.Value(), &tmp_stats) - result_map["debug"] += tmp_stats["debug"] - result_map["error"] += tmp_stats["error"] - result_map["info"] += tmp_stats["info"] - result_map["warn"] += tmp_stats["warn"] - result_map["meta"] += tmp_stats["meta"] - result_map["other"] += tmp_stats["other"] - hasPrev = iter.Prev() + // Fresh each round: Unmarshal merges into a non-nil map. + record := map[string]uint64{} + json.Unmarshal(iter.Value(), &record) + for level, count := range record { + to_return[level] += count + } } - return result_map + return to_return } func GetChartData(host string, service string, unit string, uAmount int) map[string]map[string]uint64 { @@ -246,17 +209,13 @@ func GetChartData(host string, service string, unit string, uAmount int) map[str datetime = strings.Split(string(iter.Key()), sep)[0] + formatting } if to_return[datetime] == nil { - to_return[datetime] = emptyStats() + to_return[datetime] = containerdb.NewStatCounter() + } + record := map[string]uint64{} + json.Unmarshal(iter.Value(), &record) + for level, count := range record { + to_return[datetime][level] += count } - tmp_stats := emptyStats() - json.Unmarshal(iter.Value(), &tmp_stats) - - to_return[datetime]["error"] += tmp_stats["error"] - to_return[datetime]["debug"] += tmp_stats["debug"] - to_return[datetime]["info"] += tmp_stats["info"] - to_return[datetime]["warn"] += tmp_stats["warn"] - to_return[datetime]["meta"] += tmp_stats["meta"] - to_return[datetime]["other"] += tmp_stats["other"] hasPrev = iter.Prev() } diff --git a/application/backend/app/statistics/statistics_test.go b/application/backend/app/statistics/statistics_test.go deleted file mode 100644 index 2065e7f..0000000 --- a/application/backend/app/statistics/statistics_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package statistics - -import ( - "encoding/json" - "os" - "strings" - "testing" - "time" - - "github.com/devforth/OnLogs/app/vars" - "github.com/syndtr/goleveldb/leveldb" -) - -// Reads the shared maps under the same locks the production writers hold. -func TestRunStatisticForContainer(t *testing.T) { - go RunStatisticForContainer("Test", "TestContainer") - time.Sleep(1 * time.Second) - - vars.Mutex.Lock() - counter := vars.Container_Stat_Counter["Test/TestContainer"] - vars.Mutex.Unlock() - if counter == nil { - t.Error("No counter variable for container was created!") - } - - vars.DBMutex.RLock() - statsDB := vars.Stat_Containers_DBs["Test/TestContainer"] - vars.DBMutex.RUnlock() - if statsDB == nil { - t.Error("DB for stats wasn't created!") - } -} - -func TestGetStatisticsByService(t *testing.T) { - vars.Container_Stat_Counter["test/test"] = map[string]uint64{"error": 1, "debug": 2, "info": 3, "warn": 4, "meta": 0, "other": 5} - os.RemoveAll("leveldb/hosts/test/containers/test/statistics") - statDB, _ := leveldb.OpenFile("leveldb/hosts/test/containers/test/statistics", nil) - to_put, _ := json.Marshal(vars.Container_Stat_Counter["test/test"]) - datetime := strings.Replace(strings.Split(time.Now().UTC().String(), ".")[0], " ", "T", 1) + "Z" - statDB.Put([]byte(datetime), to_put, nil) - statDB.Close() - - res := GetStatisticsByService("test", "test", 2) - if res["debug"] != 4 || res["error"] != 2 || - res["info"] != 6 || res["other"] != 10 || - res["warn"] != 8 { - t.Error("Wrong value!\n", res) - } -} - -func TestGetChartData(t *testing.T) { - // Every run appends another record to this database, and they all land in - // the same hour bucket. - os.RemoveAll("leveldb/hosts/test/statistics") - cur_db, _ := leveldb.OpenFile("leveldb/hosts/test/statistics", nil) - vars.Container_Stat_Counter["test/test"] = map[string]uint64{"error": 2, "debug": 1, "info": 3, "warn": 5, "meta": 0, "other": 4} - vars.Stat_Containers_DBs["test/test"] = cur_db - to_put, _ := json.Marshal(vars.Container_Stat_Counter["test/test"]) - datetime := strings.Replace(strings.Split(time.Now().UTC().String(), ".")[0], " ", "T", 1) + "Z" - cur_db.Put([]byte(datetime), to_put, nil) - - res := GetChartData("test", "test", "hour", 2) - datetime = datetime[:len(datetime)-6] + "00Z" - if res[datetime]["debug"] != 1 || res[datetime]["error"] != 2 || - res[datetime]["info"] != 3 || res[datetime]["other"] != 4 || - res[datetime]["warn"] != 5 || res["now"]["debug"] != 1 || - res["now"]["error"] != 2 || res["now"]["info"] != 3 || - res["now"]["other"] != 4 || res["now"]["warn"] != 5 { - t.Error("Wrong value!\n", res[datetime]) - } - -} diff --git a/application/backend/app/streamer/streamer.go b/application/backend/app/streamer/streamer.go index e97292c..2ae1c3a 100644 --- a/application/backend/app/streamer/streamer.go +++ b/application/backend/app/streamer/streamer.go @@ -18,22 +18,10 @@ type StreamController struct { DaemonService *daemon.DaemonService } -func (ctrl *StreamController) ensureStatisticsWorker(ctx context.Context, host, container string) { - statistics.EnsureWorker(ctx, host, container) -} - -func (ctrl *StreamController) stopStatisticsWorker(host, container string) { - statistics.StopWorker(host, container) -} - -func (ctrl *StreamController) statisticsWorkersCount() int { - return statistics.WorkerCount() -} - func (ctrl *StreamController) ensureStreams(ctx context.Context, containers []string) { host := util.GetHost() for _, container := range containers { - ctrl.ensureStatisticsWorker(ctx, host, container) + statistics.EnsureWorker(ctx, host, container) ctrl.DaemonService.EnsureStream(ctx, container) } } @@ -50,7 +38,7 @@ func (ctrl *StreamController) reconcileStreams(ctx context.Context) { for _, active := range vars.ActiveStreams() { if _, exists := current[active]; !exists { ctrl.DaemonService.StopStream(active) - ctrl.stopStatisticsWorker(util.GetHost(), active) + statistics.StopWorker(util.GetHost(), active) } } } @@ -64,13 +52,13 @@ func (ctrl *StreamController) handleContainerEvent(ctx context.Context, msg even switch msg.Action { case "start", "restart", "unpause": vars.AddDockerContainer(containerName) - ctrl.ensureStatisticsWorker(ctx, util.GetHost(), containerName) + statistics.EnsureWorker(ctx, util.GetHost(), containerName) ctrl.DaemonService.EnsureStream(ctx, containerName) case "die", "stop", "pause": ctrl.DaemonService.StopStream(containerName) case "destroy": ctrl.DaemonService.StopStream(containerName) - ctrl.stopStatisticsWorker(util.GetHost(), containerName) + statistics.StopWorker(util.GetHost(), containerName) } } @@ -112,8 +100,8 @@ func (ctrl *StreamController) startEventsLoop(ctx context.Context) { } func (ctrl *StreamController) StreamLogs(ctx context.Context) { - if vars.FavsDBErr != nil || vars.StateDBErr != nil || vars.UsersDBErr != nil { - fmt.Println("ERROR: unable to open leveldb", vars.FavsDBErr, vars.StateDBErr, vars.UsersDBErr) + if vars.FavsDBErr != nil || vars.UsersDBErr != nil { + fmt.Println("ERROR: unable to open leveldb", vars.FavsDBErr, vars.UsersDBErr) return } diff --git a/application/backend/app/streamer/streamer_test.go b/application/backend/app/streamer/streamer_test.go deleted file mode 100644 index 41dfb25..0000000 --- a/application/backend/app/streamer/streamer_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package streamer - -import ( - "context" - "fmt" - "testing" - - "github.com/devforth/OnLogs/app/statistics" -) - -// The registry lives in the statistics package so the docker streamer and the -// agent ingestion route share one worker per host/container. -func TestRegisterStatisticsWorkerNoDuplicates(t *testing.T) { - ctrl := &StreamController{} - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - baseline := ctrl.statisticsWorkersCount() - t.Cleanup(func() { ctrl.stopStatisticsWorker("host", "container") }) - - first := statistics.EnsureWorker(ctx, "host", "container") - second := statistics.EnsureWorker(ctx, "host", "container") - - if !first { - t.Fatal("first registration must succeed") - } - if second { - t.Fatal("duplicate registration must be rejected") - } - if got := ctrl.statisticsWorkersCount(); got != baseline+1 { - t.Fatalf("expected exactly one new worker, got %d (baseline %d)", got, baseline) - } -} - -func TestStatisticsWorkersLongChurnDoesNotLeak(t *testing.T) { - ctrl := &StreamController{} - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - baseline := ctrl.statisticsWorkersCount() - host := "churn-host" - for i := 0; i < 300; i++ { - container := fmt.Sprintf("ephemeral-%d", i) - ctrl.ensureStatisticsWorker(ctx, host, container) - ctrl.stopStatisticsWorker(host, container) - } - - if got := ctrl.statisticsWorkersCount(); got != baseline { - t.Fatalf("expected %d workers after churn, got %d", baseline, got) - } -} diff --git a/application/backend/app/userdb/userdb.go b/application/backend/app/userdb/userdb.go index cd556fd..211d3a2 100644 --- a/application/backend/app/userdb/userdb.go +++ b/application/backend/app/userdb/userdb.go @@ -25,22 +25,14 @@ func CreateUser(login string, password string) error { func GetUsers() []map[string]interface{} { var users []map[string]interface{} iter := vars.UsersDB.NewIterator(nil, nil) + defer iter.Release() + for iter.Next() { - var editable bool - if string(iter.Key()) == os.Getenv("ADMIN_USERNAME") { - editable = false - } else { - editable = true - } - - user := map[string]interface{}{ + users = append(users, map[string]interface{}{ "username": string(iter.Key()), - "editable": editable, - } - users = append(users, user) + "editable": string(iter.Key()) != os.Getenv("ADMIN_USERNAME"), + }) } - defer iter.Release() - return users } diff --git a/application/backend/app/util/util.go b/application/backend/app/util/util.go index ffb2506..2f51856 100644 --- a/application/backend/app/util/util.go +++ b/application/backend/app/util/util.go @@ -37,15 +37,6 @@ func replaceVarForAllFilesInDir(dirName string, dir_files []fs.DirEntry) { } } -func Contains(a string, list []string) bool { - for _, b := range list { - if strings.Compare(b, a) == 0 { - return true - } - } - return false -} - func CreateInitUser() error { admin_username := os.Getenv("ADMIN_USERNAME") if admin_username == "" { @@ -138,23 +129,10 @@ func GetDB(host string, container string, dbType string) *leveldb.DB { return nil } - switch dbType { - case "logs": - vars.ActiveDBs[host+"/"+container] = db - case "statistics": - vars.Stat_Containers_DBs[host+"/"+container] = db - case "hosts_statistics": - vars.Stat_Hosts_DBs[host] = db - case "statuses": - vars.Statuses_DBs[host+"/"+container] = db - case "brokenlogs": - vars.BrokenLogs_DBs[host+"/"+container] = db - case "containersmeta": - vars.ContainersMeta_DBs[host+"/"+container] = db - case "streamstate": - vars.StreamState_DBs[host+"/"+container] = db + // Assigning into a nil map panics, unlike reading or deleting. + if cache := dbCaches[dbType]; cache != nil { + cache[host+"/"+container] = db } - return db } @@ -209,43 +187,20 @@ func ResetDB(host string, container string, dbType string) { if db := getExistingDB(host, container, dbType); db != nil { db.Close() } + delete(dbCaches[dbType], host+"/"+container) +} - switch dbType { - case "logs": - delete(vars.ActiveDBs, host+"/"+container) - case "statistics": - delete(vars.Stat_Containers_DBs, host+"/"+container) - case "hosts_statistics": - delete(vars.Stat_Hosts_DBs, host) - case "statuses": - delete(vars.Statuses_DBs, host+"/"+container) - case "brokenlogs": - delete(vars.BrokenLogs_DBs, host+"/"+container) - case "containersmeta": - delete(vars.ContainersMeta_DBs, host+"/"+container) - case "streamstate": - delete(vars.StreamState_DBs, host+"/"+container) - } +// ContainersMeta_DBs is absent: GetContainersMetaDB keys it by bare host. +var dbCaches = map[string]map[string]*leveldb.DB{ + "logs": vars.ActiveDBs, + "statistics": vars.Stat_Containers_DBs, + "statuses": vars.Statuses_DBs, + "brokenlogs": vars.BrokenLogs_DBs, + "streamstate": vars.StreamState_DBs, } func getExistingDB(host, container, dbType string) *leveldb.DB { - switch dbType { - case "logs": - return vars.ActiveDBs[host+"/"+container] - case "statistics": - return vars.Stat_Containers_DBs[host+"/"+container] - case "hosts_statistics": - return vars.Stat_Hosts_DBs[host] - case "statuses": - return vars.Statuses_DBs[host+"/"+container] - case "brokenlogs": - return vars.BrokenLogs_DBs[host+"/"+container] - case "containersmeta": - return vars.ContainersMeta_DBs[host+"/"+container] - case "streamstate": - return vars.StreamState_DBs[host+"/"+container] - } - return nil + return dbCaches[dbType][host+"/"+container] } // AGENT is documented as a boolean, so "false" must mean off. diff --git a/application/backend/app/util/util_test.go b/application/backend/app/util/util_test.go index 2aae329..5a3c17a 100644 --- a/application/backend/app/util/util_test.go +++ b/application/backend/app/util/util_test.go @@ -15,28 +15,6 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } -func TestContains(t *testing.T) { - type args struct { - a string - list []string - } - tests := []struct { - name string - args args - want bool - }{ - {"Is contain 'a'", args{a: "a", list: []string{"a", "b", "c"}}, true}, - {"Is contain 'A'", args{a: "A", list: []string{"a", "b", "c"}}, false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := Contains(tt.args.a, tt.args.list); got != tt.want { - t.Errorf("Contains() = %v, want %v", got, tt.want) - } - }) - } -} - func TestCreateInitUser(t *testing.T) { os.Setenv("ADMIN_USERNAME", "admin") os.Setenv("ADMIN_PASSWORD", "an-actual-admin-password") @@ -55,27 +33,6 @@ func TestCreateInitUser(t *testing.T) { } } -func TestCreateJWT(t *testing.T) { - os.Setenv("JWT_SECRET", "1231efdZF") - token := CreateJWT("test_user") - - test_req, _ := http.NewRequest("GET", "", nil) - test_req.AddCookie( - &http.Cookie{ - Name: "onlogs-cookie", - Value: token, - }, - ) - - username, err := GetUserFromJWT(*test_req) - if err != nil { - t.Error(err) - } - if username != "test_user" { - t.Error("Username in JWT is wrong: ", username) - } -} - func TestGetHost(t *testing.T) { host, _ := os.Hostname() if host[len(host)-1] < 32 || host[len(host)-1] > 126 { diff --git a/application/backend/app/vars/state.go b/application/backend/app/vars/state.go index 2d6ad6f..867f492 100644 --- a/application/backend/app/vars/state.go +++ b/application/backend/app/vars/state.go @@ -102,7 +102,6 @@ func CheckDatabases() error { for name, err := range map[string]error{ "leveldb/favourites": FavsDBErr, "leveldb/groups": GroupsDBErr, - "leveldb/state": StateDBErr, "leveldb/users": UsersDBErr, "leveldb/tokens": TokensDBErr, "leveldb/usersSettings": SettingsDBErr, diff --git a/application/backend/app/vars/vars.go b/application/backend/app/vars/vars.go index a58dce4..d17a3b8 100644 --- a/application/backend/app/vars/vars.go +++ b/application/backend/app/vars/vars.go @@ -1,7 +1,6 @@ package vars import ( - "strconv" "sync" "sync/atomic" "time" @@ -12,7 +11,6 @@ import ( var ( ActiveDBs = map[string]*leveldb.DB{} Stat_Containers_DBs = map[string]*leveldb.DB{} - Stat_Hosts_DBs = map[string]*leveldb.DB{} Statuses_DBs = map[string]*leveldb.DB{} BrokenLogs_DBs = map[string]*leveldb.DB{} ContainersMeta_DBs = map[string]*leveldb.DB{} @@ -37,13 +35,10 @@ var ( FavsDB, FavsDBErr = leveldb.OpenFile("leveldb/favourites", nil) GroupsDB, GroupsDBErr = leveldb.OpenFile("leveldb/groups", nil) - StateDB, StateDBErr = leveldb.OpenFile("leveldb/state", nil) UsersDB, UsersDBErr = leveldb.OpenFile("leveldb/users", nil) TokensDB, TokensDBErr = leveldb.OpenFile("leveldb/tokens", nil) SettingsDB, SettingsDBErr = leveldb.OpenFile("leveldb/usersSettings", nil) - Year = strconv.Itoa(time.Now().UTC().Year()) - // Here rather than in routes so app/metrics need not import the HTTP layer. LoginFailures atomic.Uint64 LoginBlocked atomic.Uint64 diff --git a/application/backend/main.go b/application/backend/main.go index 6f14e7c..a4597e1 100644 --- a/application/backend/main.go +++ b/application/backend/main.go @@ -116,9 +116,7 @@ func main() { Client: cli, } - daemonService := &daemon.DaemonService{ - DockerClient: dockerService, - } + daemonService := daemon.NewDaemonService(dockerService) streamController := &streamer.StreamController{ DaemonService: daemonService, From 3cad2402d51630b9920db5e083830809ce20a944 Mon Sep 17 00:00:00 2001 From: Roman Malenko Date: Sat, 8 Aug 2026 20:10:11 +0300 Subject: [PATCH 26/31] fix(frontend): restore vertical padding on list items --- application/frontend/src/main.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/application/frontend/src/main.scss b/application/frontend/src/main.scss index 4aeaa33..074e720 100644 --- a/application/frontend/src/main.scss +++ b/application/frontend/src/main.scss @@ -52,6 +52,7 @@ h1 { li { list-style: none; color: inherit; + padding: 10px 0; } p { From 90581f74d08a11eb4fc719c12c730ec3f1929b79 Mon Sep 17 00:00:00 2001 From: Roman Malenko Date: Sat, 8 Aug 2026 20:12:48 +0300 Subject: [PATCH 27/31] feat(icons): add filled chart and wheel glyphs to onLogsFont --- .../src/assets/res/font/ChartFilled.svg | 5 + .../src/assets/res/font/WheelFilled.svg | 3 + .../frontend/src/assets/res/onLogsFont.css | 88 ++++++++------- .../frontend/src/assets/res/onLogsFont.eot | Bin 13992 -> 14584 bytes .../frontend/src/assets/res/onLogsFont.html | 100 +++++++++++------- .../frontend/src/assets/res/onLogsFont.svg | 84 ++++++++------- .../frontend/src/assets/res/onLogsFont.ttf | Bin 13816 -> 14408 bytes .../frontend/src/assets/res/onLogsFont.woff | Bin 8116 -> 8248 bytes .../frontend/src/assets/res/onLogsFont.woff2 | Bin 7048 -> 7220 bytes 9 files changed, 162 insertions(+), 118 deletions(-) create mode 100644 application/frontend/src/assets/res/font/ChartFilled.svg create mode 100644 application/frontend/src/assets/res/font/WheelFilled.svg diff --git a/application/frontend/src/assets/res/font/ChartFilled.svg b/application/frontend/src/assets/res/font/ChartFilled.svg new file mode 100644 index 0000000..5214b08 --- /dev/null +++ b/application/frontend/src/assets/res/font/ChartFilled.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/application/frontend/src/assets/res/font/WheelFilled.svg b/application/frontend/src/assets/res/font/WheelFilled.svg new file mode 100644 index 0000000..5a8d94c --- /dev/null +++ b/application/frontend/src/assets/res/font/WheelFilled.svg @@ -0,0 +1,3 @@ + + + diff --git a/application/frontend/src/assets/res/onLogsFont.css b/application/frontend/src/assets/res/onLogsFont.css index f470f14..e638d47 100644 --- a/application/frontend/src/assets/res/onLogsFont.css +++ b/application/frontend/src/assets/res/onLogsFont.css @@ -1,10 +1,10 @@ @font-face { font-family: "onLogsFont"; - src: url("onLogsFont.eot?b1b1fa2c569a9742068bfa91c7006c7a?#iefix") format("embedded-opentype"), -url("onLogsFont.woff2?b1b1fa2c569a9742068bfa91c7006c7a") format("woff2"), -url("onLogsFont.woff?b1b1fa2c569a9742068bfa91c7006c7a") format("woff"), -url("onLogsFont.ttf?b1b1fa2c569a9742068bfa91c7006c7a") format("truetype"), -url("onLogsFont.svg?b1b1fa2c569a9742068bfa91c7006c7a#onLogsFont") format("svg"); + src: url("onLogsFont.eot?aef3930177b47eac3029189d876a7102?#iefix") format("embedded-opentype"), +url("onLogsFont.woff2?aef3930177b47eac3029189d876a7102") format("woff2"), +url("onLogsFont.woff?aef3930177b47eac3029189d876a7102") format("woff"), +url("onLogsFont.ttf?aef3930177b47eac3029189d876a7102") format("truetype"), +url("onLogsFont.svg?aef3930177b47eac3029189d876a7102#onLogsFont") format("svg"); font-weight: normal; font-style: normal; } @@ -41,111 +41,117 @@ url("onLogsFont.svg?b1b1fa2c569a9742068bfa91c7006c7a#onLogsFont") format("svg"); .log-Chart:before { content: "\f106"; } -.log-Clean:before { +.log-ChartFilled:before { content: "\f107"; } -.log-Clock:before { +.log-Clean:before { content: "\f108"; } -.log-Close:before { +.log-Clock:before { content: "\f109"; } -.log-Colums:before { +.log-Close:before { content: "\f10a"; } -.log-Combine:before { +.log-Colums:before { content: "\f10b"; } -.log-Copy:before { +.log-Combine:before { content: "\f10c"; } -.log-Cut:before { +.log-Copy:before { content: "\f10d"; } -.log-Data:before { +.log-Cut:before { content: "\f10e"; } -.log-Down:before { +.log-Data:before { content: "\f10f"; } -.log-EmptyHeart:before { +.log-Down:before { content: "\f110"; } -.log-Error:before { +.log-EmptyHeart:before { content: "\f111"; } -.log-Eye:before { +.log-Error:before { content: "\f112"; } -.log-Filter:before { +.log-Eye:before { content: "\f113"; } -.log-Group:before { +.log-Filter:before { content: "\f114"; } -.log-Heart:before { +.log-Group:before { content: "\f115"; } -.log-Home:before { +.log-Heart:before { content: "\f116"; } -.log-Info:before { +.log-Home:before { content: "\f117"; } -.log-Json:before { +.log-Info:before { content: "\f118"; } -.log-Last:before { +.log-Json:before { content: "\f119"; } -.log-Leters:before { +.log-Last:before { content: "\f11a"; } -.log-Logout:before { +.log-Leters:before { content: "\f11b"; } -.log-Moon:before { +.log-Logout:before { content: "\f11c"; } -.log-Pencil:before { +.log-Moon:before { content: "\f11d"; } -.log-Plus:before { +.log-Pencil:before { content: "\f11e"; } -.log-Pointer:before { +.log-Plus:before { content: "\f11f"; } -.log-Refresh:before { +.log-Pointer:before { content: "\f120"; } -.log-Search:before { +.log-Refresh:before { content: "\f121"; } -.log-Server:before { +.log-Search:before { content: "\f122"; } -.log-Share:before { +.log-Server:before { content: "\f123"; } -.log-ShareLink:before { +.log-Share:before { content: "\f124"; } -.log-Success:before { +.log-ShareLink:before { content: "\f125"; } -.log-Sun:before { +.log-Success:before { content: "\f126"; } -.log-Tips:before { +.log-Sun:before { content: "\f127"; } -.log-User:before { +.log-Tips:before { content: "\f128"; } -.log-Warning:before { +.log-User:before { content: "\f129"; } -.log-Wheel:before { +.log-Warning:before { content: "\f12a"; } +.log-Wheel:before { + content: "\f12b"; +} +.log-WheelFilled:before { + content: "\f12c"; +} diff --git a/application/frontend/src/assets/res/onLogsFont.eot b/application/frontend/src/assets/res/onLogsFont.eot index 71e56cdf02500d51be41259ccf4a60ab805bd272..6d929b54c9aaf1e42b593e861673baa78b16bb06 100644 GIT binary patch delta 2368 zcmbVOX=q$k6h8OOo0%*#lchl_^b4ne+p8nLUDs){Ua&ZVt$B0YvOm_+}8XPoX7XRdzO3d zIp^N{l8+`=j$2kjL|vf^7EK*_n7Df(ayqlGxo?O_KyMn}f501f{{0~bk$Rbk4v$PG zrvra}a-YbO0Age5`6HiRnfZ&z9wFLwaXghAojY{%9r)e@v~ei%mdrOWX6zWBoSpks zTl*UE9$}6C^s$j-=ix-0$aWF-tCPt&_q3Rx?_n&5qGBpJnfjsgyHcXsJ4A|S`q<3u z2QzQ#h^<0y)6-(ReSidffxC%^Bl>0G7X$ijF_!s_im94LX^t+59YPZcaYno+u8AAs zOR=I@6ivCLEGVCoiE?N&dBAz(!=q6>g(yf3R7-wR$O>Hm;~>?M7weee>xIsbu}WKE z_tGB7UW&m>Aq$nj$C9BYC>sn{6$*9;QzPZV>;lK>APv(LoubzfA|F%3M$i^4MF5d5Ov24smiF8 z>*6+7fL+F!@FK8>)}M)LEY3?sjbs|A#)zNw6X>=Zhj%wM)7BR1q%Q1GoO)<4a^Fu0 z>ZLw9K>fHcs&Z4`WNr~I{RQ(zk+090H|gii7xfCu6+L26Q)c*>02lk>u5p_9iAHF7 zNtwuayDaQ7c?dN<6NT>Zg5OuH@H=z8Qdn(32v9@0(VHXfZ4{tClPgL(G7l#9=2up z1?&a(NbHj`>rfCX`WJl|Gw@Bv=4a6h=ENCn8<1f8pak2J5@GPLM20IFkqCfCB_iOI zgcp27f|DGRXaJ8()Pg4@g5aYPjo`F|2Rtdk-JBxI@XKiVF)%{|rX{$wCnS8}842E& zSqVS*Wr-&6D-w0!lM?mdc?oRMG7dchhkx<+yayt)TmuDw3rNeZER5R%6)L|a9Qcc~ zpoEmuCZFlD>5e+2{$g%3-?7YD7OZ5AS+81Ga!%!3%`M42lNZSQF~2|mmMvgQ+upTZ zw}$9A6ZS7J3R7ii(R~E55cNzV2yXFZ}QE;v4qkYKHGQsY~h-zWZBnmq^qG z%1Vn$EHz@=uDYV2_FuAEcFtK<V(SwcdcVr~ zcLz(#bw~veS6^gNTya(sE&fsTQiWaN&xU!%Jd5*~(iPvq!VTLTqGYoOG>f1|Y*z~O zul%L@PriubGk1^6)pLknZuNnF&)1^g_LtdK&q^gMi~WIK1^*Z<1V6NhelZ{p>hU_a zEx&aBArf+>L=@dw&f3wr&4LDcuw0{+95{8l$a5dVpvRzdC?}?MVEeFEB^<~ C@@FLg delta 2041 zcma)7TWDNG82;w$?n$!QT)MR}wXu!u)@rhs>}Io6xFF{(5pomzI68a#t5CjVa!575_!3$nMO7kM2_>jDnBE)ZI$I{2{;XD7#KmYuf zZ~i%Z|J=2#c`X9mZ@6qm?6=2)*DtqR$n`B94+0_v*!%-00-=|$4BG(v6oAvC+00zi zw+mkZW;szkIlVmL+;)b{* z{xPbIcZ?O|Q`k_5{qR#d`Sv4>21F1-6hQ=Fpn$qMjzg%2k8vFEQu?_jU`I9Aedy)f zhZL<0n0SIVW)2A|qsr7yg*1qxk-^+JNQX`6!4OX4H5AiD7|m#5h$O9?bP;Bd1a&En z9dsL40Sk2SIPD76I80BCDAMB;ce|KW7_G>)p@cOhR1GG|QObolXK|D%lLk7;*IvU_ zCxf+ewGEvrQCZ(Ey3I4<1&*FgM zQu3I~J9t_-OOhs;)qZY}#7bIAc?=CACiwrNY<0ns=6Z#7-HQiHG7UTc}9LkV+EdP27p6O$P$8el!4yArS z#=P>XgX_G%^hY~hnGX8pPo$`oBQK>>>FvVJc_mvxHboyJq;V3bFp9I7!+9)k*X-=j zqLtCp801O@}4}1d9JOh0|KP8o|m$XaSUcBZ<9&p3VCQw6k87pnpup=dfVf*v8MKpuI|nr(sT?g zBGNyQXNk%17jI#t-e{0fyv@ll1Ed0pdiv(>_j4|3V*5+J`IFqXYSdD;Y4F>~6Un2A zvO`1aIyI!O>k)Kw{6Bg)mXNl+8gkx8CP{oAOrAu8hb^nfL(bD0QYQ(P;71%MH2Nur zGzKV7YRG-3G-P2JjX32Qja&-{qZ;yyJg(uRoY0UNPijOdr!<0;XEj2UuWK|?PHT86 zvl`8mGa5@iqwfUf6tl&mrsBo-s zy{NJ1p7X @@ -217,6 +223,12 @@

    onLogsFont

    Chart
    +
    + + + + ChartFilled +
    @@ -433,5 +445,11 @@

    onLogsFont

    Wheel
    +
    + + + + WheelFilled +
    diff --git a/application/frontend/src/assets/res/onLogsFont.svg b/application/frontend/src/assets/res/onLogsFont.svg index 58a96f3..03593ea 100644 --- a/application/frontend/src/assets/res/onLogsFont.svg +++ b/application/frontend/src/assets/res/onLogsFont.svg @@ -43,222 +43,234 @@ - + + + + diff --git a/application/frontend/src/assets/res/onLogsFont.ttf b/application/frontend/src/assets/res/onLogsFont.ttf index dc9492a95baae61fa612e5daaee5702b0a18a583..e86213aa4e80063311344214fa3af0a25d49b2d3 100644 GIT binary patch delta 2364 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 diff --git a/application/frontend/src/assets/res/onLogsFont.woff b/application/frontend/src/assets/res/onLogsFont.woff index 33875db76377e16e83437267c92f3e063a971c63..41ff239c8012bcf56f6263c18139abbf96dca20a 100644 GIT binary patch delta 7973 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 delta 7797 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 HdHHw3oiF>G diff --git a/application/frontend/src/assets/res/onLogsFont.woff2 b/application/frontend/src/assets/res/onLogsFont.woff2 index 60309d519bb8e464fcb8de0dc0e2fa422ff69e14..c93396f5c80b4cf02dde1113f0bd1f9b15c55431 100644 GIT binary patch literal 7220 zcmV-49LwW(Pew8T0RR91030*`3jhEB060hh02|=|0RR9100000000000000000000 z0000ShGGU_0E|Kk%@n|X0X7081A6pSD1v5)S>X>&EEW>WY-)5g z`mrkwcG01fJOKNv-k&`{NgNO>w1dtZ zYN=@ff(j%Tk+B@00*NBqf%p~D4Y`UGGccj88&i*Cj+K#}bOI1N0*(P3OzsDN zGoPGHp!C}Fwlk^BM39+Op2>NBlI<_q{>}xN3edR#iGcrq&0agRm{tL@uss#JzQR;L zm}KYOcW0BA)8VZw_C6idbrjIX(5^iE%kkt?N}jF`+|O)CSYZ9Df&bE8p$1hJ!{{h1dc0M@}W zbYni4vFWCKc-uZK`?3MJpEkHzTsddWzJTRy7ZBfv(7zfE{yp_EQjw)VOvcq8oO&5DMo4!OXOt@06J z!{T`5SVG#hcanIUbKY&wy(UoDn}sn`&T&!RZeas~r_zu>geiC)mQGb_R^cQ(F-JN^ zD7zosV@b$QrWB@Od2U)ZVWfFCEqrPzlf++1H3nA96jLTX!ACT-{Xu=kKbH#LDF z8>Op{d9WtNU0BV$h7L|ZcE4q%LBnc`Yfnm;f8ib$F_7BX{RyR1s*8854DnDNP3eFh zWGE|5CE`1Uk$s}ymI0o73yOV6c!hWZZ;FQ>M*W5xQg*4y^c}MgsVEJzVIUYcT7Eabv)K zQWKY(7yTy7*VWwN)yjWqeQp^%^mKNJ)&t~}k#80+;}5ljJgsbTQm zh9=CKcg+5<48|*g5>#c>hArjNyz%*v>Am+eJ3BH+k14 zH(}pizpx{5QK|M988It@dM@8xfX4LbGEX4xJ~1+dN~1H>)HO7>V7PK+x8*U415(Jv!xgw_m_*$K@xd4jEFFZ^Vl5H)|vLI$Kp!H<4O2 zb<6Yv<>W9cO@=98gyR8ro>y4kZd&W(VydSi9{bFvOrEf3S~ZVob>Z)1Az7 zU<$LQF>eOvc5ST4pUO%6t@#Nft_kx13q z+T37y;kwA&NqlkXFW238*fkRcUOJlL*&2{f5zZa*!kp9bwm4rEre-)C|XBt4rCDr0GvP7nhAi~ z$l?KUAo+&AM+pDKKlmn-xzEn~?jbjBZ7V|VHc7Iyh93g1bO)~mvaH=j?_?sgY<$13!pyi_fAxnc`? z0~+n8YR{ts#YIcx{YU+pad-$WH+OB7^ zw~vJ*GsT$N6^r2=d#c;3U00eny2AIBHH_n%sniAs%+lgj!?VgI84~GFyGWKqPvZ(J z5slw~J@RU(yc9uRLH+-!%^~Z^B1y%_Ep4oXx_T4;An}?yqm3jaYHKv~ou=w*>!`1& z+YdXj!bPK6g&VB#sxW;K>{I$&j7Rd$%Q?E8K<(34!}YJOc!+_dA)BVuJo9=>?mCdR zTz`W>ER^tMPi1P3l4W{f$xRQxcLW>6&fKyn+*0?z3ky%~0tsftAB>S0eIwYih`LRw2%icl&X@^_krI4PTkBTqQnU#q>wia7EoWHzk_ezskL2B z;6M7M8taJg+2ar>gXGgQ^!zjZ`VznRat^;D(Jp(Tt+E3*-@fm09o5#W*arXQ}Y|7nc*LsC<$ zS8`z+t*C858mT-|gq{t}ZG3qFiVrmt16Qd#Q2JTS!Jq88;Ob0XqcoC z2d4%rUor3>!cOI8KDgC&K4bA}PdH-WG=BM&etxcJx*Po>F`_O{`~`OaX3vFOyw_xh zziRlwjlQ{GWx@000(5QGiC1xr^SeORDp2XycRqPAZANat{}@w6xx)!}qbC9eNe4?tcCS^Gi^-Q9p?joUPB2Pvn^x`;SmjTGvOiz zfh`^7E;m5aq;=bf?q~C%Bqk)mI9+HP8KIYnLW9e26$H9Tj4LUV9;@L&eT|kXYYQd? z^Su?;b#Sj{Ad502ip_yq)g{F^|94a8P~NDNJy6>Gy$50yFqH{3osit|?A4cO5(KlZ zNUkKrfPuMe@buW{$ny&Cs>)P8HWX(&x?2b2zoc}&oQJr@EpCw-_y?2ltGi>=#r7|J z8n2*JSyEcBtgDW4Y+~VNIO``(*xq!K_tVkJY0LGzhf39?-%(tO;GK`|6uH@S6m7cSMc-tGHV>^sZO`QN-C|`q!seXu7@YY*&@io*@<(5PS@dK ztr)hT^6}<+kG9ctyqCvI`7t|x4v!+N(+gfvE%enieAWDvj49OU(_UGDH~D<|)6u&% zcWyqhKf86W_P%klM=*dsTUe&1V{~|wg4uf_pskmbzV+6XTO`!3qyDWui<*;$)a)NW z+@iiL5~hn~C*RDZHqJVgILn*P7Mh!|2co%i(UIwE?}Z9WuyA#R{5dQiIS zV2A&iC3q9*`Q$w(yZyE$^*6^{Ar2)z$sxbEAb*yU_AQ6W7saQmW6vq}9@!djHfZN< z#U<75Q+?XD-sZ>&(f!i%342cVclfX5sYmZRSQYcA!U1S|=%&pBU6ji*JcibumOkvx z*xwbG;&-3=eQUt+ppD*pq#7#b^twOXzUB09q#-%UW=yKAC!v1%;Ve70N>Gk$<~oP1 z_M!x-^Y-L#Hlzhy3P@}CKF&2b zJE0x2^X*VO!A_k9+3^5xkE3;fm&O{*N#F#(*+dnh@ID2e@`j?x7Q7;vGx|7_H&ZMu zAzhbeQ8rKJ^s&PaIO~Hs!Iz!GuCIBGev1y2%yTS&)n_h{E)dSN#oI)F_1-HNxR6|e z7AW`mh3#OCx9%`O+4K%HDFK9;Au94ke5f*ql(P?YhRh8$EwvFGq6Y-q(%6!i*>i-( z*y<0y;bPISsHkIu-Qr9v5&@T>7b*V&UkA#OzV3X!0Odf@wzb0{C&@61-VDSQj@bt- zUe~-VgZ9D3YYR5F7bm)vShAg#6xbP&Qsy-Av|dxZ^K`^3%jmE*YMUlbjt%5 z2E0_sR*&X(?{s&Lzc~<9d+DHhKP8u0;;#%UJl1BZHPv;UA)@NDPBOEo8QmMNjCNLT z8l_53JTH9w-P<13QU-4#*3AQ6461PRQc~`n`jx<~)0_r;>-DY%>7whylk-1eAoh>csvJKZXMdG8aqoPnzaRJ%)~CjAUyv1crQ?>K znivNdyT(kj7zOm!R=RSto|9O$wz}KUpL2%A=wUQugdb-wkF4e{9vocEt&YSdNwUa( z#gWxm#xw1CgR|j&^!ITE7J+1Bgp2dT*??A$?^XArF=%x9#p(lSR~)cZFB&``Fd&!% zCL^!eD|0vdpv}v-a9YnIP~R8nXRvE*dxD$rGuRS(bIrz&Xslr$NYDyJ#-(-l_h ztJlS%hoa))VY*^JcRLwXxub$6@89w8L=yDoOt|2jK+z_2$ zkuhXpeF4s4`uM{XXmcJ(ia4v+sRc zv^+Y4sip7n@9(oT^h{>-ba`4&n%wBsCjuFaYlGC5#WC{K(%3g}$?++P01#iA@K$_G zdAY=-1KiiIIRc4ha0QJ5&VPd#ybW+A;{fF35F?1Fa#llCkX{E}l=B}u;o(CcLKUG} zudAXlEexfyz+dmD^P770YX85SzYe}qiN(qh`SD8s0=BD9y=HZO6jCH&)&q>4yr9+oGXK@G zb0lzyR8mnPX(Dm9$&JTJ$z@v)&Iu_C{b&pYIf`WCgJtY2i`$*WXgT+=Uf2}NAxV-q zz_6#LKF$E%>zYB2-(arr@u+%VVP1;y@VV*3GeHwRstMZ(>W_q0On|edVuK$T8|x$g z6o!`_!)-Qa00!*n$IUUkyhvWITfM>GfVG@IZ^7Oc$u7ggl*kXvh(m$dIuGrWQdaSHO-lK$w(C{rPs$NSn>pwf>a@s5vi<9L8(y5D2(sS%<8H$Jp1z#|6cqj$6d$U zt+E}@JNo7+LvH2?4TWp7GK^M0{FA%l+#7S7A5QKYtIW2t)y1Os*nPgGkVUQh`K^-g%iejAk~VG3vPH<;Hbkl$WaP>(7LFhN(_3N(r~^|k%Vt1Z0z z+O{98ZTkkm)VgdOsW=tajY};K#-VY+#bTVS4aUiM^dgn2h|WXv^k6C#y@=NxMK2Ec z#ZhvcP&H2OnOaQ3b#1B0^46^sT;rpnZ1vG<9P~v?1gFM)5O|@mvn-csv@Q)bvCBH| zKMLCvy3}f9j=s|7=5p&UIcj+g{Pk<=dDRh@E=5%H>esI4H}Gm5m+H8qqsDdGsAXFr zeNaKktD}|_1nHrz%c8VO??kCRA}?Nyj86n|ZUNx%IL@;=r<*N+EnJwFZ?*n}vV;+W z<2SK5+=bAIpNBVTwI+PAoq)01jI-g{Y`l%XX$BT=#MPoNtV#)38IZE-!u?&o=zI(C zmu7+k>Y8b7maLTRsI{^yn0jGn}4ChM8BziZyb+xRfi5y7Zx(?Jj)M6Xzt{?Faih}n24-6;a(FtcW9&oF>InHE>1%Xac7HMGl&|tdk7i><;Rubxqf+m;qeKZltkqw zN2JnROco{+^HNPsL}Yqq=SrpC-YkNJb1+irrg&m}lZ;RF@t%3PY;KRqJ4P;#@f6dl z3{u(>XiJH@&A*emYggu-HzdC7oy<4BpiVz?=6?p?*zepq!+*XcmLvazai^WeE;{2e z|Aoucty`VUan`um{7QUg&r`EH)}||wmr+X8Wy(2l_wqyPL*kHqxJ=9{Z*R;akI)Q~ zSWL>$k(jJn-7y&Y59~h}UZ4Br<%KV*C%GK)+Xs;Su^0L&=j$Sqj>7zx;Z;|ra-Q## zzs@tup1oMW*!6e$747ED+AEJqOjhmV;{rL-nKOaG7Zlq9F39Cbz_SiliG__0oDEt) zBIyU``HD+q?r+2F@S$o>*k?PFZ(Iv*;{-dhXAK7g)q6?G%S0v1IH|0>!PT4p%ALyt ziOEbgt3QazBKyca$9Q*6PKQ@nRaXJ0DB^DtlhvSdhlyEC&)>YMkY=F23KAs9{t7UQ zO}-D&6VVR=k#xR!Ls53ohUU%}3qrhD$?1(@>0yoOSHLS|0r=eW5cM1wgVLd~0nbeS zvFqe?2w_9qLo@|9oqZdEe*C0ih)vFZtq6c@`t^_S@gFDhjO7f{sjzNLuld88n%dYw zJt4eaRtWM`LQL^^cnAH&`OPu&2jwXZr+BT^`9y!3=vn+f0I;k{&rerfmsRggd|Lou-%dIXXGQPCrQc>?e8-vU zbZ*l%9Xt>G3XiipKiU-W8jvkU)sAQuS&_4jN!{{koq*Z7FQ@9dPG`&6e`!u^3Dc@^ z01u)k0N}FyGP#~G1zc|AHuPQ#%~T4`lS5-jR7}69d^h*L z`swxbzsJ#4RCdU2DRXmQ*`INL_iH1WyV79!j!Zg>yDtoM^@Z$(w1S719$)&|p0#U; z9`e6h3+MDqrI6ET$Se{ncD-LzzAKk7)z6!)c*s=lj&@}SgzKa?QVZKp3WD9P0My%&kw>VPSPwdjEqf8&CD$< zt*mWq?d%;Kot#}<-P{95HZ;gS9kI%b2LFth>2of!+{a@^`A)_)XUOp+m+b$Z6X%67 z_=+b6rQPg6>K)@SnUJYVm%dvmpM}L}LRA}1Y9CFdmrA0K{WB{o-!3r*70(h0aX4!+ zQ6;?ciUm_$#ZTIemQ-T2vYMi`BO7g{^T`$kTxU%gscUaXWRoLWRhZ~#YL_0Vuul`x zGipYcCzn`2(Hu8a@g>s*{65ET3;RwlwNmI@cvjo!T2wB&Ik^oyB-_Gf@gqkxe~DAj z_XS2^0D-}Sf*cBHfZzjX$Y2Z--cZ8?=I{aw4oY}J1+DJw<@PJkZ08*{UV3x@0000% CPSQ32 literal 7048 zcmV;38+YV)Pew8T0RR9102_z^3jhEB05$jk02?&`0RR9100000000000000000000 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

    qNDrf`}ik{n!nLRFen2*_x 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 Date: Sat, 8 Aug 2026 20:13:24 +0300 Subject: [PATCH 28/31] feat(hosts): add per-host display aliases with a rename API --- .../backend/app/hostalias/hostalias.go | 50 ++++++++++++ .../backend/app/hostalias/hostalias_test.go | 76 +++++++++++++++++++ application/backend/app/routes/groups_test.go | 40 ++++++++++ application/backend/app/routes/routes.go | 34 +++++++++ application/backend/app/vars/state.go | 1 + application/backend/app/vars/vars.go | 1 + application/backend/main.go | 4 +- 7 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 application/backend/app/hostalias/hostalias.go create mode 100644 application/backend/app/hostalias/hostalias_test.go diff --git a/application/backend/app/hostalias/hostalias.go b/application/backend/app/hostalias/hostalias.go new file mode 100644 index 0000000..f2fb620 --- /dev/null +++ b/application/backend/app/hostalias/hostalias.go @@ -0,0 +1,50 @@ +package hostalias + +import ( + "errors" + "fmt" + "strings" + + "github.com/devforth/OnLogs/app/util" + "github.com/devforth/OnLogs/app/vars" +) + +const MaxAliasBytes = 64 + +func Validate(host string, alias string) error { + if !util.IsSafeName(host) { + return errors.New("Invalid host") + } + if len(alias) > MaxAliasBytes { + return fmt.Errorf("Display name can not be longer than %d bytes", MaxAliasBytes) + } + if strings.ContainsRune(alias, 0) { + return errors.New("Display name can not contain a NUL byte") + } + if strings.TrimSpace(alias) != alias { + return errors.New("Display name can not start or end with whitespace") + } + return nil +} + +// An empty alias clears it, so the sidebar falls back to the real hostname. +func Set(host string, alias string) error { + if err := Validate(host, alias); err != nil { + return err + } + if alias == "" { + return vars.AliasDB.Delete([]byte(host), nil) + } + return vars.AliasDB.Put([]byte(host), []byte(alias), nil) +} + +func All() (map[string]string, error) { + iter := vars.AliasDB.NewIterator(nil, nil) + defer iter.Release() + + aliases := map[string]string{} + for iter.Next() { + aliases[string(iter.Key())] = string(iter.Value()) + } + return aliases, iter.Error() +} diff --git a/application/backend/app/hostalias/hostalias_test.go b/application/backend/app/hostalias/hostalias_test.go new file mode 100644 index 0000000..2167bc0 --- /dev/null +++ b/application/backend/app/hostalias/hostalias_test.go @@ -0,0 +1,76 @@ +package hostalias + +import ( + "strings" + "testing" + + "github.com/devforth/OnLogs/app/vars" +) + +func TestValidateRejectsUnsafeInput(t *testing.T) { + cases := []struct { + label string + host string + alias string + wantErr bool + }{ + {"plain", "myhost", "prod box", false}, + {"clearing", "myhost", "", false}, + {"exactly 64 bytes", "myhost", strings.Repeat("a", 64), false}, + {"65 bytes", "myhost", strings.Repeat("a", 65), true}, + {"NUL in alias", "myhost", "prod\x00box", true}, + {"leading space", "myhost", " prod", true}, + {"trailing space", "myhost", "prod ", true}, + {"host escaping its directory", "..", "prod", true}, + {"host with a separator", "a/b", "prod", true}, + {"empty host", "", "prod", true}, + } + + for _, c := range cases { + t.Run(c.label, func(t *testing.T) { + err := Validate(c.host, c.alias) + if c.wantErr && err == nil { + t.Fatalf("Validate(%q, %q) = nil, want an error", c.host, c.alias) + } + if !c.wantErr && err != nil { + t.Fatalf("Validate(%q, %q) = %v, want nil", c.host, c.alias, err) + } + }) + } +} + +func TestSetAndClearRoundTrip(t *testing.T) { + const host = "alias-round-trip-host" + t.Cleanup(func() { vars.AliasDB.Delete([]byte(host), nil) }) + + if err := Set(host, "prod box"); err != nil { + t.Fatalf("Set: %v", err) + } + aliases, err := All() + if err != nil { + t.Fatalf("All: %v", err) + } + if aliases[host] != "prod box" { + t.Fatalf("All()[%q] = %q, want %q", host, aliases[host], "prod box") + } + + if err := Set(host, ""); err != nil { + t.Fatalf("clearing: %v", err) + } + aliases, _ = All() + if _, present := aliases[host]; present { + t.Fatalf("the alias survived being cleared: %q", aliases[host]) + } +} + +func TestSetRejectsInvalidWithoutWriting(t *testing.T) { + const host = "alias-reject-host" + t.Cleanup(func() { vars.AliasDB.Delete([]byte(host), nil) }) + + if err := Set(host, strings.Repeat("a", 65)); err == nil { + t.Error("Set accepted an oversized display name") + } + if aliases, _ := All(); aliases[host] != "" { + t.Errorf("a rejected alias was stored anyway: %q", aliases[host]) + } +} diff --git a/application/backend/app/routes/groups_test.go b/application/backend/app/routes/groups_test.go index 6127f1f..718c084 100644 --- a/application/backend/app/routes/groups_test.go +++ b/application/backend/app/routes/groups_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/devforth/OnLogs/app/groups" + "github.com/devforth/OnLogs/app/hostalias" ) func groupNames(t *testing.T, body []byte) []string { @@ -280,3 +281,42 @@ func TestDisableAuthSharesOneGroupBucket(t *testing.T) { t.Fatalf("a second anonymous createGroup returned %d, want 409: %s", status, body) } } + +func TestHostAliasIsAdminOnlyToSet(t *testing.T) { + t.Cleanup(func() { hostalias.Set("aliashost", "") }) + + status, body := call(t, testCtrl.SetHostAlias, + authedRequest(t, "viewer", "POST", "/", map[string]string{"host": "aliashost", "alias": "prod"})) + if status != http.StatusForbidden { + t.Fatalf("a non-admin set a host alias: status %d, %s", status, body) + } + if aliases, _ := hostalias.All(); aliases["aliashost"] != "" { + t.Fatalf("a non-admin's alias was stored: %q", aliases["aliashost"]) + } + + status, body = call(t, testCtrl.SetHostAlias, + authedRequest(t, "admin", "POST", "/", map[string]string{"host": "aliashost", "alias": "prod"})) + if status != http.StatusOK { + t.Fatalf("admin could not set a host alias: status %d, %s", status, body) + } + + status, body = call(t, testCtrl.GetHostAliases, authedRequest(t, "viewer", "GET", "/", nil)) + if status != http.StatusOK { + t.Fatalf("a normal user could not read host aliases: status %d, %s", status, body) + } + var aliases map[string]string + if err := json.Unmarshal(body, &aliases); err != nil { + t.Fatalf("unmarshalling aliases: %v -- %s", err, body) + } + if aliases["aliashost"] != "prod" { + t.Errorf("getHostAliases returned %v, want aliashost=prod", aliases) + } +} + +func TestHostAliasRejectsAnUnsafeHost(t *testing.T) { + status, _ := call(t, testCtrl.SetHostAlias, + authedRequest(t, "admin", "POST", "/", map[string]string{"host": "../../etc", "alias": "pwn"})) + if status != http.StatusBadRequest { + t.Fatalf("a traversing host was accepted: status %d", status) + } +} diff --git a/application/backend/app/routes/routes.go b/application/backend/app/routes/routes.go index e7f9ece..c8f7f57 100644 --- a/application/backend/app/routes/routes.go +++ b/application/backend/app/routes/routes.go @@ -21,6 +21,7 @@ import ( "github.com/devforth/OnLogs/app/db" "github.com/devforth/OnLogs/app/docker" "github.com/devforth/OnLogs/app/groups" + "github.com/devforth/OnLogs/app/hostalias" "github.com/devforth/OnLogs/app/statistics" "github.com/devforth/OnLogs/app/userdb" "github.com/devforth/OnLogs/app/util" @@ -457,6 +458,39 @@ func (h *RouteController) DeleteGroup(w http.ResponseWriter, req *http.Request) ok(w) } +func (h *RouteController) GetHostAliases(w http.ResponseWriter, req *http.Request) { + if !guard(w, req, authUser, "") { + return + } + + aliases, err := hostalias.All() + if err != nil { + fail(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, aliases) +} + +func (h *RouteController) SetHostAlias(w http.ResponseWriter, req *http.Request) { + if !guard(w, req, authAdmin, "POST") { + return + } + + var body struct { + Host string `json:"host"` + Alias string `json:"alias"` + } + if !decodeBody(w, req, &body) { + return + } + + if err := hostalias.Set(body.Host, body.Alias); err != nil { + fail(w, http.StatusBadRequest, err.Error()) + return + } + ok(w) +} + func (h *RouteController) GetSecret(w http.ResponseWriter, req *http.Request) { if !guard(w, req, authAdmin, "") { return diff --git a/application/backend/app/vars/state.go b/application/backend/app/vars/state.go index 867f492..475d6d0 100644 --- a/application/backend/app/vars/state.go +++ b/application/backend/app/vars/state.go @@ -100,6 +100,7 @@ func TakeQueuedDeletes(host string) []string { func CheckDatabases() error { failures := []string{} for name, err := range map[string]error{ + "leveldb/hostaliases": AliasDBErr, "leveldb/favourites": FavsDBErr, "leveldb/groups": GroupsDBErr, "leveldb/users": UsersDBErr, diff --git a/application/backend/app/vars/vars.go b/application/backend/app/vars/vars.go index d17a3b8..d84d198 100644 --- a/application/backend/app/vars/vars.go +++ b/application/backend/app/vars/vars.go @@ -33,6 +33,7 @@ var ( DBMutex sync.RWMutex connectionsMutex sync.RWMutex + AliasDB, AliasDBErr = leveldb.OpenFile("leveldb/hostaliases", nil) FavsDB, FavsDBErr = leveldb.OpenFile("leveldb/favourites", nil) GroupsDB, GroupsDBErr = leveldb.OpenFile("leveldb/groups", nil) UsersDB, UsersDBErr = leveldb.OpenFile("leveldb/users", nil) diff --git a/application/backend/main.go b/application/backend/main.go index a4597e1..f679a8a 100644 --- a/application/backend/main.go +++ b/application/backend/main.go @@ -165,6 +165,7 @@ func main() { http.HandleFunc(pathPrefix+"/api/v1/getChartData", routerCtrl.GetChartData) http.HandleFunc(pathPrefix+"/api/v1/getDockerSize", routerCtrl.GetDockerSize) http.HandleFunc(pathPrefix+"/api/v1/getGroups", routerCtrl.GetGroups) + http.HandleFunc(pathPrefix+"/api/v1/getHostAliases", routerCtrl.GetHostAliases) http.HandleFunc(pathPrefix+"/api/v1/getHosts", routerCtrl.GetHosts) http.HandleFunc(pathPrefix+"/api/v1/getLogWithPrev", routerCtrl.GetLogWithPrev) http.HandleFunc(pathPrefix+"/api/v1/getLogs", routerCtrl.GetLogs) @@ -179,9 +180,8 @@ func main() { http.HandleFunc(pathPrefix+"/api/v1/getUsers", routerCtrl.GetUsers) http.HandleFunc(pathPrefix+"/api/v1/login", routerCtrl.Login) http.HandleFunc(pathPrefix+"/api/v1/logout", routerCtrl.Logout) - // Registered even when METRICS_TOKEN is unset: the pathPrefix+"/" catch-all - // below answers anything unregistered with index.html and a 200. http.HandleFunc(pathPrefix+"/api/v1/metrics", metrics.Handler(daemonService)) + http.HandleFunc(pathPrefix+"/api/v1/setHostAlias", routerCtrl.SetHostAlias) http.HandleFunc(pathPrefix+"/api/v1/updateGroup", routerCtrl.UpdateGroup) http.HandleFunc(pathPrefix+"/api/v1/updateUserSettings", routerCtrl.UpdateUserSettings) From bcb3d2c8837a9d6eb3e83a484c71731aed78aa42 Mon Sep 17 00:00:00 2001 From: Roman Malenko Date: Sat, 8 Aug 2026 20:13:42 +0300 Subject: [PATCH 29/31] feat(chart): render log volume from real API data instead of mocks --- application/frontend/src/Stores/stores.js | 2 +- .../frontend/src/lib/ChartMenu/Chart.svelte | 256 ++++++------------ .../frontend/src/lib/ChartMenu/ChartMenu.scss | 93 +++++-- .../src/lib/ChartMenu/MainChartMenu.svelte | 77 ++++-- .../frontend/src/lib/ChartMenu/chartData.js | 61 +++++ .../src/lib/ChartMenu/chartData.test.mjs | 70 +++++ application/frontend/src/utils/functions.js | 15 - 7 files changed, 337 insertions(+), 237 deletions(-) create mode 100644 application/frontend/src/lib/ChartMenu/chartData.js create mode 100644 application/frontend/src/lib/ChartMenu/chartData.test.mjs diff --git a/application/frontend/src/Stores/stores.js b/application/frontend/src/Stores/stores.js index 24091b2..5c63a05 100644 --- a/application/frontend/src/Stores/stores.js +++ b/application/frontend/src/Stores/stores.js @@ -75,7 +75,7 @@ export const lastLogTimestamp = writable(0); //stats export const lastStatsPeriod = writable(2); -export const lastStatisticPeriod = writable("Per hour"); +export const lastStatisticPeriod = writable("hour"); //spiner diff --git a/application/frontend/src/lib/ChartMenu/Chart.svelte b/application/frontend/src/lib/ChartMenu/Chart.svelte index b4f13ae..9f6780e 100644 --- a/application/frontend/src/lib/ChartMenu/Chart.svelte +++ b/application/frontend/src/lib/ChartMenu/Chart.svelte @@ -1,194 +1,94 @@ -

    - {#if data} {/if} +
    + {#if total === 0} +

    No statistics recorded for this period yet

    + {:else} + + {/if}
    diff --git a/application/frontend/src/lib/ChartMenu/ChartMenu.scss b/application/frontend/src/lib/ChartMenu/ChartMenu.scss index c0e0a09..e3fa033 100644 --- a/application/frontend/src/lib/ChartMenu/ChartMenu.scss +++ b/application/frontend/src/lib/ChartMenu/ChartMenu.scss @@ -1,26 +1,85 @@ -.chartHeader { - ul { - margin-top: 12px; - // gap: 6px; +.chartContainer { + display: flex; + flex-direction: column; + height: 100%; + gap: 20px; + + .chartHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding-bottom: 16px; + border-bottom: 1px solid $lines-color; + + @include for-mobile { + flex-direction: column; + gap: 8px; + } + } + + .chartTittle { + font-size: $main-font-l; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .chartSubtitle { + margin-top: 4px; + font-size: $main-font-s; + color: $text-placeholder-color; + font-variant-numeric: tabular-nums; + } + + .timeSpan { + gap: 4px; + flex-shrink: 0; + font-size: $main-font-s; + + & div { + padding: 4px 12px; + border-radius: 100px; + font-weight: 600; + cursor: pointer; + color: $text-placeholder-color; + + &:hover { + color: $text-dark-color; + } + &.active { + color: $text-onactive-color; + background-color: $active-color; + } + } } - .item { + .chartCanvas { flex: 1; + min-height: 320px; + display: flex; + align-items: center; + justify-content: center; + } + + .chartEmpty { font-size: $main-font-m; - box-shadow: $main-shadow-dark; + color: $text-placeholder-color; + } +} - cursor: pointer; - &.isActive { - color: $active-color; - } - &:first-child { - border-top-left-radius: $main-border-radius; - border-bottom-left-radius: $main-border-radius; +.dark-mode .chartContainer { + .chartHeader { + border-bottom-color: $lines-color-dark; + } + .timeSpan div { + &:hover { + color: $text-dark-color-dark; } - &:last-child { - border-top-right-radius: $main-border-radius; - border-bottom-right-radius: $main-border-radius; + &.active { + color: $text-onactive-color-dark; + background-color: $active-color-dark; } - // border-radius: $main-border-radius; } } diff --git a/application/frontend/src/lib/ChartMenu/MainChartMenu.svelte b/application/frontend/src/lib/ChartMenu/MainChartMenu.svelte index ee02634..3ed9400 100644 --- a/application/frontend/src/lib/ChartMenu/MainChartMenu.svelte +++ b/application/frontend/src/lib/ChartMenu/MainChartMenu.svelte @@ -1,43 +1,68 @@
    -

    Logs statistic:

    -
    -
      - {#each headerOptions as option} -
    • { - lastStatisticPeriod.set(option); - }} +
      +

      {chartService}

      +

      + {full.format(total)} log lines in the last {span} +

      +
      + +
      + {#each PERIODS as [value, label]} +
      lastStatisticPeriod.set(value)} > - {option} -
    • {/each} -
    + {label} +
    + {/each} +
    - + +
    diff --git a/application/frontend/src/lib/ChartMenu/chartData.js b/application/frontend/src/lib/ChartMenu/chartData.js new file mode 100644 index 0000000..e04070b --- /dev/null +++ b/application/frontend/src/lib/ChartMenu/chartData.js @@ -0,0 +1,61 @@ +export const LEVELS = [ + ["error", "Error", "#ff4242"], + ["warn", "Warn", "#ff8a00"], + ["info", "Info", "#87ceeb"], + ["debug", "Debug", "#178f15"], + ["meta", "Meta", "#4e49da"], + ["other", "Other", "#94a3b8"], +]; + +export const PERIODS = [ + ["hour", "Hour"], + ["day", "Day"], + ["month", "Month"], +]; + +// "now" is the unflushed interval, so it is pinned right rather than sorted. +export function orderBuckets(buckets = {}) { + const keys = Object.keys(buckets); + return keys + .filter((b) => b !== "now") + .sort() + .concat(keys.includes("now") ? ["now"] : []); +} + +export function bucketLabel(bucket, unit) { + if (bucket === "now") { + return "now"; + } + const date = new Date(bucket); + if (Number.isNaN(date.getTime())) { + return bucket; + } + if (unit === "hour") { + return date.toLocaleString([], { hour: "2-digit", minute: "2-digit" }); + } + if (unit === "day") { + return date.toLocaleString([], { month: "short", day: "2-digit" }); + } + return date.toLocaleString([], { month: "short", year: "numeric" }); +} + +export function totalLines(buckets = {}) { + return orderBuckets(buckets).reduce( + (sum, b) => sum + LEVELS.reduce((n, [key]) => n + (buckets[b]?.[key] ?? 0), 0), + 0 + ); +} + +export function toChartData(buckets = {}, unit = "hour") { + const ordered = orderBuckets(buckets); + return { + labels: ordered.map((b) => bucketLabel(b, unit)), + datasets: LEVELS.map(([key, name, colour]) => ({ + label: name, + data: ordered.map((b) => buckets[b]?.[key] ?? 0), + stack: "Stack 0", + backgroundColor: colour, + borderWidth: 1, + })), + }; +} diff --git a/application/frontend/src/lib/ChartMenu/chartData.test.mjs b/application/frontend/src/lib/ChartMenu/chartData.test.mjs new file mode 100644 index 0000000..4efb04e --- /dev/null +++ b/application/frontend/src/lib/ChartMenu/chartData.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { main } from "../../../test/harness.mjs"; +import { orderBuckets, toChartData, totalLines, LEVELS } from "./chartData.js"; + +const RESPONSE = { + "2026-08-08T14:00Z": { debug: 1, error: 2, info: 3, meta: 4, other: 5, warn: 6 }, + "2026-08-08T05:00Z": { debug: 0, error: 0, info: 0, meta: 1, other: 0, warn: 0 }, + now: { debug: 0, error: 7, info: 0, meta: 0, other: 0, warn: 0 }, +}; + +main(async () => { + assert.deepEqual( + orderBuckets(RESPONSE), + ["2026-08-08T05:00Z", "2026-08-08T14:00Z", "now"], + "buckets sort oldest-first with now pinned to the right edge" + ); + + assert.deepEqual(orderBuckets({}), [], "an empty response yields no buckets"); + assert.deepEqual( + orderBuckets({ now: {} }), + ["now"], + "a response holding only the live interval still charts it" + ); + + const data = toChartData(RESPONSE, "hour"); + + assert.equal( + data.datasets.length, + 6, + "every severity gets a series -- meta is the only non-zero level on many containers" + ); + assert.deepEqual( + data.datasets.map((d) => d.label), + ["Error", "Warn", "Info", "Debug", "Meta", "Other"] + ); + + const meta = data.datasets.find((d) => d.label === "Meta"); + assert.deepEqual(meta.data, [1, 4, 0], "series values follow the bucket order"); + + const error = data.datasets.find((d) => d.label === "Error"); + assert.deepEqual(error.data, [0, 2, 7], "the live interval is the last point"); + + assert.equal(data.labels.at(-1), "now", "the live interval is labelled, not timestamped"); + assert.equal(data.labels.length, 3); + assert.ok( + !data.labels.slice(0, -1).includes("1"), + "labels are real timestamps, not the placeholder the emulated data used" + ); + + const sparse = toChartData({ "2026-08-08T14:00Z": { error: 3 } }, "hour"); + for (const series of sparse.datasets) { + assert.ok( + series.data.every((n) => Number.isFinite(n)), + `${series.label} produced a non-numeric point from a sparse bucket` + ); + } + + // 21 in the 14:00 bucket + 1 in the 05:00 bucket + 7 live. + assert.equal(totalLines(RESPONSE), 29, "totals sum every level across every bucket"); + assert.equal(totalLines({}), 0, "an empty response reports nothing to draw"); + assert.equal( + totalLines({ "2026-08-08T14:00Z": { debug: 0, error: 0 } }), + 0, + "all-zero buckets count as nothing to draw, so the empty state shows" + ); + + assert.equal(LEVELS.length, 6); + console.log(` ${data.labels.length} buckets, ${data.datasets.length} series, ${totalLines(RESPONSE)} lines`); + console.log("chart data mapping tests passed"); +}); diff --git a/application/frontend/src/utils/functions.js b/application/frontend/src/utils/functions.js index b1ab887..b20ceb7 100644 --- a/application/frontend/src/utils/functions.js +++ b/application/frontend/src/utils/functions.js @@ -5,21 +5,6 @@ export const handleKeydown = (e, keyValue, cb) => { } }; -export const emulateData = (amount) => { - const randomArray = (length, max) => - [...new Array(length)].map(() => Math.round(Math.random() * max)); - - let data = { - dates: new Array(amount).fill("1"), - debug: randomArray(10, 1000), - error: randomArray(10, 1000), - info: randomArray(10, 1000), - warn: randomArray(10, 1000), - other: randomArray(10, 1000), - }; - return data; -}; - export const tryToParseLogString = (str) => { const beginningOfJson = str.search(/[{[]/); const endingOfJson = str.search(/[\]}](?![\s\S]*[\]}])/); From 79cba953a2240055e1b45ba9b46f82231ba620d0 Mon Sep 17 00:00:00 2001 From: Roman Malenko Date: Sat, 8 Aug 2026 20:14:29 +0300 Subject: [PATCH 30/31] feat(sidebar): rename hosts and open stats or settings from the list --- application/frontend/src/App.svelte | 4 +- application/frontend/src/Stores/stores.js | 3 + .../frontend/src/Views/Main/Main.svelte | 33 +++-- .../ServiceSettingsLeft.svelte | 10 -- .../lib/HostAliasModal/HostAliasModal.scss | 63 ++++++++ .../lib/HostAliasModal/HostAliasModal.svelte | 75 ++++++++++ .../lib/ListWithChoise/ListWithChoise.scss | 34 +++-- .../lib/ListWithChoise/ListWithChoise.svelte | 135 ++++++++++++------ application/frontend/src/main.scss | 1 + application/frontend/src/utils/fetch.js | 6 + application/frontend/src/utils/hostAliases.js | 9 ++ 11 files changed, 294 insertions(+), 79 deletions(-) delete mode 100644 application/frontend/src/Views/ServiceSettings/ServiceSettingsLeft.svelte create mode 100644 application/frontend/src/lib/HostAliasModal/HostAliasModal.scss create mode 100644 application/frontend/src/lib/HostAliasModal/HostAliasModal.svelte create mode 100644 application/frontend/src/utils/hostAliases.js diff --git a/application/frontend/src/App.svelte b/application/frontend/src/App.svelte index 828a2b5..1532efc 100644 --- a/application/frontend/src/App.svelte +++ b/application/frontend/src/App.svelte @@ -33,8 +33,10 @@ if (LStheme) { theme.set(LStheme); } + const routeNamesContainer = + /\/(view|stats|servicesettings)\/[^/]+\/[^/]+/.test(location.pathname); const LSHostData = window.localStorage.getItem("lsthd"); - if (LSHostData) { + if (LSHostData && !routeNamesContainer) { try { const data = JSON.parse(LSHostData); lastChosenHost.set(data.h); diff --git a/application/frontend/src/Stores/stores.js b/application/frontend/src/Stores/stores.js index 5c63a05..7d1d3dd 100644 --- a/application/frontend/src/Stores/stores.js +++ b/application/frontend/src/Stores/stores.js @@ -25,6 +25,9 @@ export const theme = writable("light"); export const lastChosenHost = writable(""); export const lastChosenService = writable(""); +export const hostAliases = writable({}); +export const hostBeingRenamed = writable(null); + // service groups, loaded from /api/v1/getGroups export const groups = writable([]); export const groupModalIsVisible = writable(false); diff --git a/application/frontend/src/Views/Main/Main.svelte b/application/frontend/src/Views/Main/Main.svelte index c290278..3503f91 100644 --- a/application/frontend/src/Views/Main/Main.svelte +++ b/application/frontend/src/Views/Main/Main.svelte @@ -20,9 +20,12 @@ chosenLogsString, store, groupModalIsVisible, + hostBeingRenamed, } from "../../Stores/stores.js"; import GroupModal from "../../lib/GroupModal/GroupModal.svelte"; + import HostAliasModal from "../../lib/HostAliasModal/HostAliasModal.svelte"; import { reloadGroups } from "../../utils/groups.js"; + import { reloadHostAliases } from "../../utils/hostAliases.js"; import UserMenu from "../../lib/UserMenu/UserMenu.svelte"; import Modal from "../../lib/Modal/Modal.svelte"; import UserManageForm from "../../lib/UserMenu/UserManageForm.svelte"; @@ -37,7 +40,6 @@ import LogsSize from "../../lib/LogsSize/LogsSize.svelte"; import ConfirmationMenu from "../../lib/ConfirmationMenu/ConfirmationMenu.svelte"; import ServiceSettings from "../ServiceSettings/ServiceSettings.svelte"; - import ServiceSettingsLeft from "../ServiceSettings/ServiceSettingsLeft.svelte"; import { lastLogTimestamp } from "../../Stores/stores.js"; import { changeKey } from "../../utils/changeKey.js"; import Stats from "../../lib/Stats/Stats.svelte"; @@ -69,6 +71,12 @@ let { host = "", service = "" } = $props(); + const section = location.pathname.includes("/stats") + ? "stats" + : location.pathname.includes("/servicesettings") + ? "settings" + : "logs"; + function closeModal() { addUserModalOpen.set(false); } @@ -111,6 +119,7 @@ // Groups only change when this user changes them, so they are loaded once // rather than folded into the host polling. reloadGroups(); + reloadHostAliases(); const data = await getHosts(); intervalId = setInterval(async () => { await getHosts(); @@ -148,6 +157,13 @@ unsubscribeAddUserModal(); }); + $effect(() => { + if (host && service && service !== "undefined") { + lastChosenHost.set(host); + lastChosenService.set(service); + } + }); + // $: { // if (withoutRightPanelRoutesArr.includes(location.pathname.split("/")[1])) { // withoutRightPanel = true; @@ -159,9 +175,7 @@
    { listScrollIsVisible.set(true); @@ -211,10 +225,10 @@
    - {#if location.pathname.includes("/view") || location.pathname === `${changeKey}` || location.pathname === `/ONLOGS_PREFIX_ENV_VARIABLE_THAT_SHOULD_BE_REPLACED_ON_BACKEND_INITIALIZATION/` || location.pathname === "/" || location.pathname.includes("/stats")} + {#if location.pathname.includes("/view") || location.pathname === `${changeKey}` || location.pathname === `/ONLOGS_PREFIX_ENV_VARIABLE_THAT_SHOULD_BE_REPLACED_ON_BACKEND_INITIALIZATION/` || location.pathname === "/" || location.pathname.includes("/stats") || location.pathname.includes("/servicesettings")} {/if} - {#if location.pathname.includes("/servicesettings")} - {/if}
    @@ -248,7 +260,7 @@ {#if location.pathname.includes("/servicesettings")} {/if} {#if location.pathname.includes("/stats")} - {/if} + {/if} {#if $snipetModalIsVisible} @@ -257,6 +269,9 @@ {#if $groupModalIsVisible} {/if} + {#if $hostBeingRenamed} + + {/if}
    - - diff --git a/application/frontend/src/lib/HostAliasModal/HostAliasModal.scss b/application/frontend/src/lib/HostAliasModal/HostAliasModal.scss new file mode 100644 index 0000000..41fa5dc --- /dev/null +++ b/application/frontend/src/lib/HostAliasModal/HostAliasModal.scss @@ -0,0 +1,63 @@ +.hostAliasModal { + min-width: 320px; + max-width: 420px; + + @include for-mobile { + min-width: auto; + width: 76vw; + } + + h3 { + margin-bottom: 4px; + // Modal.svelte pins its close button at the top right. + padding-right: 40px; + color: $text-dark-color; + } + + .hostAliasReal { + font-family: $mono-font; + font-size: $main-font-s; + color: $text-placeholder-color; + margin-bottom: 16px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .hostAliasInput { + width: 100%; + height: 40px; + box-sizing: border-box; + padding: 0 $normal-padding; + font-family: inherit; + font-size: $main-font-m; + background-color: $inpun-color; + border: 1px solid $text-placeholder-color; + border-radius: $main-border-radius; + color: $text-dark-color; + + &:focus { + outline: none; + border: 2px solid $active-color; + } + } + + .hostAliasHint { + margin-top: 10px; + font-size: $main-font-xs; + line-height: 1.5; + color: $text-placeholder-color; + } + + .hostAliasButtons { + display: flex; + justify-content: end; + margin-top: 24px; + } +} + +.dark-mode .hostAliasModal { + .hostAliasInput:focus { + border-color: $active-color-dark; + } +} diff --git a/application/frontend/src/lib/HostAliasModal/HostAliasModal.svelte b/application/frontend/src/lib/HostAliasModal/HostAliasModal.svelte new file mode 100644 index 0000000..2961ab3 --- /dev/null +++ b/application/frontend/src/lib/HostAliasModal/HostAliasModal.svelte @@ -0,0 +1,75 @@ + + + +
    +

    Rename host

    +

    {$hostBeingRenamed}

    + + +

    + Shown instead of the machine's own hostname. Logs stay stored under + the real name. Leave empty to show the real name again. +

    + +
    +
    +
    +
    diff --git a/application/frontend/src/lib/ListWithChoise/ListWithChoise.scss b/application/frontend/src/lib/ListWithChoise/ListWithChoise.scss index 71b9e97..4f10c20 100644 --- a/application/frontend/src/lib/ListWithChoise/ListWithChoise.scss +++ b/application/frontend/src/lib/ListWithChoise/ListWithChoise.scss @@ -92,12 +92,13 @@ } .listElementButton { - .log-Heart { + .log-Heart, + .log-ChartFilled, + .log-WheelFilled { opacity: 1; } - .log-EmptyHeart { - opacity: 0; - } + .log-EmptyHeart, + .log-Chart, .log-Wheel { opacity: 0; } @@ -105,9 +106,8 @@ .serviceListItem:hover { opacity: 0.8; - .log-EmptyHeart { - opacity: 1; - } + .log-EmptyHeart, + .log-Chart, .log-Wheel { opacity: 1; } @@ -139,17 +139,21 @@ .stopedServicesBox { margin-top: 4px; margin-bottom: 4px; + } - .log-Pointer { - font-size: 10px; - cursor: pointer; - padding: 6px; - transform: rotate(0); - &.rotated { - transform: rotate(-90deg); - } + .log-Pointer { + font-size: 10px; + cursor: pointer; + padding: 6px; + transform: rotate(0); + &.rotated { + transform: rotate(-90deg); } } + + .hostToggle { + margin-left: auto; + } .buttonBox { display: flex; gap: 8px; diff --git a/application/frontend/src/lib/ListWithChoise/ListWithChoise.svelte b/application/frontend/src/lib/ListWithChoise/ListWithChoise.svelte index f18030c..e746d47 100644 --- a/application/frontend/src/lib/ListWithChoise/ListWithChoise.svelte +++ b/application/frontend/src/lib/ListWithChoise/ListWithChoise.svelte @@ -1,16 +1,17 @@ {#snippet serviceRow(host, service, i, idSuffix)} + {@const isCurrent = + `${activeElementName}` === `${host.trim()}-${service.serviceName.trim()}`}
  • { - navigate( - `${changeKey}/servicesettings/${host.trim()}/${service.serviceName.trim()}`, - { replace: true } - ); - chosenElSettings = `${host.trim()}-${service.serviceName.trim()}`; - }} + title={isCurrent && section === "stats" + ? "Back to logs" + : "Log statistics"} + onclick={(e) => + openSection( + e, + host, + service.serviceName, + isCurrent && section === "stats" ? "view" : "stats" + )} > - + +
    +
    + openSection( + e, + host, + service.serviceName, + isCurrent && section === "settings" ? "view" : "servicesettings" + )} + > +
    {/if}
    -
    +
  • {/snippet} @@ -310,29 +356,28 @@
      {#each sortedData as listEl, index}
    • -
      { - if (target.id !== headerButton) { - toggleSublistVisible(index); - } - }} - > +
      toggleSublistVisible(index)}>
      -

      - {listEl.host} +

      + {$hostAliases[listEl.host] || listEl.host}

      - {#if headerButton}
      { - console.log("clicable"); - }} - > - -
      {/if} +
      { + e.stopPropagation(); + hostBeingRenamed.set(listEl.host); + }} + > + +
      +
      +
      Date: Sun, 9 Aug 2026 20:23:49 +0300 Subject: [PATCH 31/31] feat(login): key the rate limiter on the real client behind trusted proxies --- README.md | 20 +++ application/backend/app/routes/loginlimit.go | 120 +++++++++++++++++- .../backend/app/routes/loginlimit_test.go | 107 ++++++++++++++++ application/backend/main.go | 6 + 4 files changed, 249 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e5929a9..75e2409 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ - ADMIN_USERNAME=admin - ADMIN_PASSWORD= - PORT=8798 + - TRUSTED_PROXIES=172.16.0.0/12 # the docker network traefik reaches OnLogs over, see below # - ONLOGS_PATH_PREFIX=/onlogs if want to use with path prefix labels: @@ -93,6 +94,25 @@ Once done, just go to and login as "admin" with . | MAX_LOGS_SIZE | Maximum allowed total logs size before cleanup triggers. Accepts human-readable formats like 5GB, 500MB, 1.5GB etc. When exceeded, 10% of logs (by count) will be removed proportionally across containers starting from oldest. Validated at startup: an unparseable value stops OnLogs rather than silently disabling retention | 10GB | - | DISABLE_AUTH | Option to completely disable built in authentication in the application. When this option is set to `true` the app will behave like if the Administrator is logged in. The option to manage users will be removed. | false | - | METRICS_TOKEN | Bearer token for the Prometheus endpoint at `/api/v1/metrics`. While it is unset the endpoint returns `401` and exposes nothing, so metrics are off by default. See [Metrics](#metrics) | | only for `/api/v1/metrics` +| TRUSTED_PROXIES | Peers allowed to name the client through `X-Forwarded-For` / `X-Real-IP`, as comma separated IPs and CIDR ranges. See [Behind a reverse proxy](#behind-a-reverse-proxy) | | only if behind nginx/traefik + +## Behind a reverse proxy + +Failed logins are rate limited per client address. Behind nginx or traefik every request +arrives from the proxy, so without `TRUSTED_PROXIES` all your users count as one client and +a single password sprayer locks out the rest. Set it to the addresses your proxy connects +from: + +``` +TRUSTED_PROXIES=172.16.0.0/12 # docker networks land here, so any containerised proxy does too +TRUSTED_PROXIES=127.0.0.1 # a proxy on the host +``` + +Keep the range as tight as your proxy allows — anything reaching OnLogs from a listed address +can call itself any client. + +Make sure the proxy actually sends the headers — traefik does by default, nginx needs +`proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`. ## Metrics diff --git a/application/backend/app/routes/loginlimit.go b/application/backend/app/routes/loginlimit.go index a23f439..6365dc0 100644 --- a/application/backend/app/routes/loginlimit.go +++ b/application/backend/app/routes/loginlimit.go @@ -3,8 +3,11 @@ package routes import ( "crypto/sha256" "encoding/hex" + "fmt" "net" "net/http" + "net/netip" + "strings" "sync" "time" ) @@ -110,12 +113,121 @@ func (l *loginAttempts) prune(now time.Time) { } } -// RemoteAddr only: X-Forwarded-For is caller-controlled and would let an -// attacker rotate past the limit. +// Empty means no peer may speak for anyone else. +var trustedProxies []netip.Prefix + +// SetTrustedProxies takes a comma separated list of IPs and CIDR ranges. +func SetTrustedProxies(spec string) error { + parsed, err := parseTrustedProxies(spec) + if err != nil { + return err + } + trustedProxies = parsed + return nil +} + +func parseTrustedProxies(spec string) ([]netip.Prefix, error) { + var parsed []netip.Prefix + for _, item := range strings.Split(spec, ",") { + item = strings.TrimSpace(item) + switch { + case item == "": + case strings.Contains(item, "/"): + prefix, err := netip.ParsePrefix(item) + if err != nil { + return nil, fmt.Errorf("%q is not a CIDR range: %w", item, err) + } + parsed = append(parsed, prefix.Masked()) + default: + addr, err := netip.ParseAddr(item) + if err != nil { + return nil, fmt.Errorf("%q is not an IP address or CIDR range: %w", item, err) + } + addr = addr.Unmap() + parsed = append(parsed, netip.PrefixFrom(addr, addr.BitLen())) + } + } + return parsed, nil +} + +func isTrustedProxy(addr netip.Addr) bool { + for _, prefix := range trustedProxies { + if prefix.Contains(addr) { + return true + } + } + return false +} + +// Zones and the IPv4-in-IPv6 form are dropped so one client cannot hold two +// buckets. +func parseIP(value string) (netip.Addr, bool) { + value = strings.TrimSpace(value) + if addr, err := netip.ParseAddr(value); err == nil { + return addr.Unmap().WithZone(""), true + } + if addrPort, err := netip.ParseAddrPort(value); err == nil { + return addrPort.Addr().Unmap().WithZone(""), true + } + return netip.Addr{}, false +} + +// The peer address, unless it is a trusted proxy: then the address it +// forwarded. The headers are caller-controlled, so taking them from anyone +// would let an attacker rotate past the limit or wear someone else's address. func clientAddr(req *http.Request) string { host, _, err := net.SplitHostPort(req.RemoteAddr) if err != nil { - return req.RemoteAddr + host = req.RemoteAddr + } + + peer, ok := parseIP(host) + if !ok { + return host + } + if !isTrustedProxy(peer) { + warnUntrustedForward(req) + return peer.String() + } + if forwarded, ok := forwardedClient(req); ok { + return forwarded + } + return peer.String() +} + +// Right to left: every hop appends the address it saw, so the rightmost entry +// no trusted proxy could have written is the last one still worth believing. +func forwardedClient(req *http.Request) (string, bool) { + entries := strings.Split(strings.Join(req.Header.Values("X-Forwarded-For"), ","), ",") + origin := "" + for i := len(entries) - 1; i >= 0; i-- { + addr, ok := parseIP(entries[i]) + if !ok { + break + } + if !isTrustedProxy(addr) { + return addr.String(), true + } + origin = addr.String() + } + + if addr, ok := parseIP(req.Header.Get("X-Real-IP")); ok { + return addr.String(), true + } + // Every hop was trusted, so the chain names its own origin. + return origin, origin != "" +} + +var forwardWarning sync.Once + +// Either a proxy nobody told us about, whose users then share one bucket, or +// someone trying on another address for size. +func warnUntrustedForward(req *http.Request) { + if req.Header.Get("X-Forwarded-For") == "" && req.Header.Get("X-Real-IP") == "" { + return } - return host + forwardWarning.Do(func() { + fmt.Printf("WARNING: a login from %s carried a forwarded client address, but that peer is not in TRUSTED_PROXIES;"+ + " rate limiting keys on the peer, so everyone behind it shares one limit.\n", req.RemoteAddr) + }) } diff --git a/application/backend/app/routes/loginlimit_test.go b/application/backend/app/routes/loginlimit_test.go index 4f01199..31f0b72 100644 --- a/application/backend/app/routes/loginlimit_test.go +++ b/application/backend/app/routes/loginlimit_test.go @@ -85,6 +85,83 @@ func TestLoginLimiterStillThrottlesRepeatedFailures(t *testing.T) { _ = time.Second } +func withTrustedProxies(t *testing.T, spec string) { + t.Helper() + if err := SetTrustedProxies(spec); err != nil { + t.Fatalf("SetTrustedProxies(%q): %v", spec, err) + } + t.Cleanup(func() { trustedProxies = nil }) +} + +func forwardedRequest(peer string, headers map[string]string) *http.Request { + req, _ := http.NewRequest("POST", "/api/v1/login", nil) + req.RemoteAddr = peer + for name, value := range headers { + req.Header.Set(name, value) + } + return req +} + +func TestForwardedAddressesAreIgnoredFromUntrustedPeers(t *testing.T) { + req := forwardedRequest("203.0.113.9:40000", map[string]string{ + "X-Forwarded-For": "198.51.100.7", + "X-Real-IP": "198.51.100.7", + }) + + if got := clientAddr(req); got != "203.0.113.9" { + t.Fatalf("a caller wore another address with no proxy configured: %q", got) + } + + // Trusting some other proxy is not trusting this caller. + withTrustedProxies(t, "192.0.2.1,10.0.0.0/8") + if got := clientAddr(req); got != "203.0.113.9" { + t.Fatalf("a caller outside TRUSTED_PROXIES wore another address: %q", got) + } +} + +func TestClientAddrResolvesThroughTrustedProxies(t *testing.T) { + withTrustedProxies(t, "10.0.0.0/8,172.16.0.0/12") + + cases := []struct { + name string + peer string + headers map[string]string + want string + }{ + {"single hop", "10.0.0.1:40000", map[string]string{"X-Forwarded-For": "198.51.100.7"}, "198.51.100.7"}, + {"chained proxies", "10.0.0.1:40000", map[string]string{"X-Forwarded-For": "198.51.100.7, 10.0.0.5"}, "198.51.100.7"}, + {"client prepended a fake hop", "10.0.0.1:40000", map[string]string{"X-Forwarded-For": "1.1.1.1, 198.51.100.7"}, "198.51.100.7"}, + {"x-real-ip only", "10.0.0.1:40000", map[string]string{"X-Real-IP": "198.51.100.7"}, "198.51.100.7"}, + {"entry carries a port", "10.0.0.1:40000", map[string]string{"X-Forwarded-For": "198.51.100.7:1234"}, "198.51.100.7"}, + {"ipv4 in ipv6 form", "[::ffff:10.0.0.1]:40000", map[string]string{"X-Forwarded-For": "::ffff:198.51.100.7"}, "198.51.100.7"}, + {"unparseable chain", "10.0.0.1:40000", map[string]string{"X-Forwarded-For": "not-an-address"}, "10.0.0.1"}, + {"proxy forwarded nothing", "10.0.0.1:40000", nil, "10.0.0.1"}, + {"internal end to end", "10.0.0.1:40000", map[string]string{"X-Forwarded-For": "10.9.9.9, 10.0.0.5"}, "10.9.9.9"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := clientAddr(forwardedRequest(tc.peer, tc.headers)); got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestTrustedProxySpecIsValidated(t *testing.T) { + if _, err := parseTrustedProxies(" 10.0.0.0/8 , 192.0.2.1,::1 "); err != nil { + t.Fatalf("a valid spec was rejected: %v", err) + } + if parsed, err := parseTrustedProxies(""); err != nil || parsed != nil { + t.Fatalf("an empty spec gave (%v, %v)", parsed, err) + } + for _, spec := range []string{"10.0.0.0/99", "not-an-address", "10.0.0.1-10.0.0.5", "10.0.0.0/8,junk"} { + if _, err := parseTrustedProxies(spec); err == nil { + t.Errorf("%q was accepted", spec) + } + } +} + // The lockout lived in which key the handler chose, so it has to be exercised // through the handler. func TestLoginLockoutCannotBeInflictedOnAnotherUser(t *testing.T) { @@ -116,3 +193,33 @@ func TestLoginLockoutCannotBeInflictedOnAnotherUser(t *testing.T) { t.Fatalf("an attacker guessing at the account locked its real owner out: status %d", code) } } + +func TestOneAttackerBehindAProxyDoesNotThrottleEveryoneElse(t *testing.T) { + userdb.CreateUser("proxieduser", "the-real-password") + t.Cleanup(func() { userdb.DeleteUser("proxieduser") }) + withTrustedProxies(t, "10.0.0.0/8,172.16.0.0/12") + + loginLimiter.mu.Lock() + loginLimiter.entries = map[string]*loginAttempt{} + loginLimiter.mu.Unlock() + + attempt := func(client, password string) int { + body, _ := json.Marshal(map[string]string{"Login": "proxieduser", "Password": password}) + req, _ := http.NewRequest("POST", "/api/v1/login", bytes.NewBuffer(body)) + req.RemoteAddr = "172.18.0.2:40000" + req.Header.Set("X-Forwarded-For", client) + rr := httptest.NewRecorder() + http.HandlerFunc(testCtrl.Login).ServeHTTP(rr, req) + return rr.Result().StatusCode + } + + for i := 0; i < 20; i++ { + attempt("203.0.113.9", "guess-"+strconv.Itoa(i)) + } + if code := attempt("203.0.113.9", "the-real-password"); code != http.StatusTooManyRequests { + t.Errorf("the attacker was not throttled through the proxy: status %d", code) + } + if code := attempt("198.51.100.7", "the-real-password"); code != http.StatusOK { + t.Fatalf("one attacker behind the proxy locked out everyone else: status %d", code) + } +} diff --git a/application/backend/main.go b/application/backend/main.go index f679a8a..904e0d9 100644 --- a/application/backend/main.go +++ b/application/backend/main.go @@ -84,6 +84,12 @@ func init_config() { os.Exit(1) } + if err := routes.SetTrustedProxies(os.Getenv("TRUSTED_PROXIES")); err != nil { + fmt.Printf("FATAL: TRUSTED_PROXIES=%q is invalid (%v); refusing to start with a rate limiter that cannot tell clients apart.\n", + os.Getenv("TRUSTED_PROXIES"), err) + os.Exit(1) + } + fmt.Println("INFO: OnLogs configs done!") }
    UserRoleManage user
    UserRoleManage user
    {user.username} + >{user.username} {#if user.editable} { + onclick={() => { setChosenUserLogin(user.username); showUserEditing(); }}>