From 43ee5a7d4b8470060269cdebba636f6f52695de8 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Thu, 9 Jul 2026 13:55:36 -0500 Subject: [PATCH] change around pings, fix main.js so the actual real fucking number shows instead of it freaking out. --- .../cmd/agent/handlers/dispatcher_test.go | 72 +++++++++++++++++++ Overlord-Client/cmd/agent/handlers/extract.go | 36 ++++++---- .../cmd/agent/handlers/extract_test.go | 16 +++++ Overlord-Client/cmd/agent/handlers/ping.go | 4 +- Overlord-Server/public/assets/main.js | 13 +++- Overlord-Server/src/httpHandlers.ts | 6 +- .../src/server/maintenance-loops.test.ts | 9 ++- .../src/server/maintenance-loops.ts | 15 +++- .../routes/client-command-routes.test.ts | 47 ++++++++++++ .../server/routes/client-command-routes.ts | 27 +++++-- Overlord-Server/src/wsHandlers.test.ts | 63 ++++++++++++++++ Overlord-Server/src/wsHandlers.ts | 17 +++-- 12 files changed, 293 insertions(+), 32 deletions(-) create mode 100644 Overlord-Server/src/server/routes/client-command-routes.test.ts diff --git a/Overlord-Client/cmd/agent/handlers/dispatcher_test.go b/Overlord-Client/cmd/agent/handlers/dispatcher_test.go index 52277b1..5efa069 100644 --- a/Overlord-Client/cmd/agent/handlers/dispatcher_test.go +++ b/Overlord-Client/cmd/agent/handlers/dispatcher_test.go @@ -76,6 +76,78 @@ func TestHandlePing(t *testing.T) { } } +func TestHandlePingPreservesZeroTimestamp(t *testing.T) { + writer := &testWriter{} + env := &rt.Env{ + Conn: writer, + Cfg: config.Config{}, + } + + ctx := context.Background() + envelope := map[string]interface{}{ + "ts": int64(0), + } + + if err := HandlePing(ctx, env, envelope); err != nil { + t.Fatalf("HandlePing failed: %v", err) + } + + deadline := time.After(2 * time.Second) + for len(writer.msgs) < 1 { + select { + case <-deadline: + t.Fatal("Timed out waiting for pong message") + default: + time.Sleep(5 * time.Millisecond) + } + } + + var pong wire.Pong + if err := msgpack.Unmarshal(writer.msgs[0], &pong); err != nil { + t.Fatalf("Failed to unmarshal pong: %v", err) + } + if pong.TS != 0 { + t.Fatalf("expected timestamp 0, got %d", pong.TS) + } +} + +func TestHandlePingFallsBackForInvalidTimestamp(t *testing.T) { + writer := &testWriter{} + env := &rt.Env{ + Conn: writer, + Cfg: config.Config{}, + } + + ctx := context.Background() + before := time.Now().UnixMilli() + envelope := map[string]interface{}{ + "ts": "not-a-timestamp", + } + + if err := HandlePing(ctx, env, envelope); err != nil { + t.Fatalf("HandlePing failed: %v", err) + } + + deadline := time.After(2 * time.Second) + for len(writer.msgs) < 1 { + select { + case <-deadline: + t.Fatal("Timed out waiting for pong message") + default: + time.Sleep(5 * time.Millisecond) + } + } + + var pong wire.Pong + if err := msgpack.Unmarshal(writer.msgs[0], &pong); err != nil { + t.Fatalf("Failed to unmarshal pong: %v", err) + } + after := time.Now().UnixMilli() + if pong.TS < before || pong.TS > after { + t.Fatalf("expected fallback timestamp between %d and %d, got %d", before, after, pong.TS) + } +} + func TestHandleCommand_Ping(t *testing.T) { writer := &testWriter{} env := &rt.Env{ diff --git a/Overlord-Client/cmd/agent/handlers/extract.go b/Overlord-Client/cmd/agent/handlers/extract.go index b89f4c3..e7d71d3 100644 --- a/Overlord-Client/cmd/agent/handlers/extract.go +++ b/Overlord-Client/cmd/agent/handlers/extract.go @@ -3,36 +3,44 @@ package handlers import "encoding/json" func extractTimestamp(v interface{}) int64 { + ts, ok := extractTimestampIfPresent(v) + if !ok { + return 0 + } + return ts +} + +func extractTimestampIfPresent(v interface{}) (int64, bool) { switch t := v.(type) { case uint64: if t <= uint64(^uint64(0)>>1) { - return int64(t) + return int64(t), true } - return 0 + return 0, false case uint32: - return int64(t) + return int64(t), true case uint16: - return int64(t) + return int64(t), true case uint8: - return int64(t) + return int64(t), true case int64: - return t + return t, true case int32: - return int64(t) + return int64(t), true case int16: - return int64(t) + return int64(t), true case int8: - return int64(t) + return int64(t), true case int: - return int64(t) + return int64(t), true case float64: - return int64(t) + return int64(t), true case float32: - return int64(t) + return int64(t), true case json.Number: if n, err := t.Int64(); err == nil { - return n + return n, true } } - return 0 + return 0, false } diff --git a/Overlord-Client/cmd/agent/handlers/extract_test.go b/Overlord-Client/cmd/agent/handlers/extract_test.go index 0c03ca9..93c32b1 100644 --- a/Overlord-Client/cmd/agent/handlers/extract_test.go +++ b/Overlord-Client/cmd/agent/handlers/extract_test.go @@ -129,6 +129,22 @@ func TestExtractTimestamp_Zero(t *testing.T) { } } +func TestExtractTimestampIfPresent_ZeroIsValid(t *testing.T) { + got, ok := extractTimestampIfPresent(int64(0)) + if !ok { + t.Fatal("expected zero timestamp to be valid") + } + if got != 0 { + t.Fatalf("expected 0, got %d", got) + } +} + +func TestExtractTimestampIfPresent_InvalidIsNotValid(t *testing.T) { + if got, ok := extractTimestampIfPresent("hello"); ok || got != 0 { + t.Fatalf("expected invalid timestamp to return ok=false and 0, got ok=%v value=%d", ok, got) + } +} + func TestExtractTimestamp_Negative(t *testing.T) { var ts int64 = -100 if got := extractTimestamp(ts); got != ts { diff --git a/Overlord-Client/cmd/agent/handlers/ping.go b/Overlord-Client/cmd/agent/handlers/ping.go index 21e9ddf..466a89c 100644 --- a/Overlord-Client/cmd/agent/handlers/ping.go +++ b/Overlord-Client/cmd/agent/handlers/ping.go @@ -10,8 +10,8 @@ import ( func HandlePing(ctx context.Context, env *runtime.Env, envelope map[string]interface{}) error { - ts := extractTimestamp(envelope["ts"]) - if ts == 0 { + ts, ok := extractTimestampIfPresent(envelope["ts"]) + if !ok { ts = time.Now().UnixMilli() } env.SetLastPong(time.Now().UnixMilli()) diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index 4baec14..1812939 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -1119,7 +1119,9 @@ function initializeRenderer() { rendererSetLayout = rSetLayout; registerRenderer((data, options) => { renderMerge(data, options); - updateDashboardStatsFromClients(data); + if (isUnfilteredClientView()) { + updateDashboardStatsFromClients(data); + } if (!options?.fromPluginDashboard) { refreshDashboardPluginContributions(data?.items || []); } @@ -1151,6 +1153,15 @@ function initializeRenderer() { } } +function isUnfilteredClientView() { + return !state.searchTerm && + (state.filterStatus || "all") === "all" && + (state.filterOs || "all") === "all" && + (state.filterCountry || "all") === "all" && + (state.filterGroup || "all") === "all" && + (state.filterWebcam || "all") === "all"; +} + if (logoutBtn && !logoutBtn.dataset.boundLogout) { logoutBtn.dataset.boundLogout = "true"; logoutBtn.addEventListener("click", async () => { diff --git a/Overlord-Server/src/httpHandlers.ts b/Overlord-Server/src/httpHandlers.ts index 25f47e4..51deac7 100644 --- a/Overlord-Server/src/httpHandlers.ts +++ b/Overlord-Server/src/httpHandlers.ts @@ -5,6 +5,7 @@ import { encodeMessage } from "./protocol"; import { v4 as uuidv4 } from "uuid"; import { metrics } from "./metrics"; import { logAudit, AuditAction } from "./auditLog"; +import { sendPingRequest } from "./wsHandlers"; const DEFAULT_PAGE_SIZE = 12; const CORS_HEADERS = { @@ -61,10 +62,7 @@ export function handleCommand(target: ClientInfo, action: string, req: Request) metrics.recordCommand(action); if (action === "ping") { - const nonce = Date.now() + Math.floor(Math.random() * 1000); - target.lastPingSent = Date.now(); - target.lastPingNonce = nonce; - target.ws.send(encodeMessage({ type: "ping", ts: nonce })); + sendPingRequest(target, target.ws, "manual", 0); return OK(); } diff --git a/Overlord-Server/src/server/maintenance-loops.test.ts b/Overlord-Server/src/server/maintenance-loops.test.ts index 97615cc..0736890 100644 --- a/Overlord-Server/src/server/maintenance-loops.test.ts +++ b/Overlord-Server/src/server/maintenance-loops.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { getHeartbeatBatchSize } from "./maintenance-loops"; +import { getHeartbeatBatchSize, shouldSendHeartbeatPing } from "./maintenance-loops"; describe("maintenance heartbeat batching", () => { test("spreads large fleets across the heartbeat interval", () => { @@ -13,4 +13,11 @@ describe("maintenance heartbeat batching", () => { test("handles empty fleets", () => { expect(getHeartbeatBatchSize(0, 30_000)).toBe(0); }); + + test("only sends heartbeat pings after the per-client interval", () => { + const now = Date.now(); + expect(shouldSendHeartbeatPing({}, now, 30_000)).toBe(true); + expect(shouldSendHeartbeatPing({ lastPingSent: now - 29_999 }, now, 30_000)).toBe(false); + expect(shouldSendHeartbeatPing({ lastPingSent: now - 30_000 }, now, 30_000)).toBe(true); + }); }); diff --git a/Overlord-Server/src/server/maintenance-loops.ts b/Overlord-Server/src/server/maintenance-loops.ts index 6b20b00..e860abd 100644 --- a/Overlord-Server/src/server/maintenance-loops.ts +++ b/Overlord-Server/src/server/maintenance-loops.ts @@ -35,6 +35,16 @@ export function getHeartbeatBatchSize(totalClients: number, heartbeatIntervalMs: return Math.max(1, Math.ceil(totalClients / ticksPerSweep)); } +export function shouldSendHeartbeatPing( + info: Pick, + now: number, + heartbeatIntervalMs: number, +): boolean { + if (heartbeatIntervalMs <= 0) return false; + if (!info.lastPingSent) return true; + return now - info.lastPingSent >= heartbeatIntervalMs; +} + export function startMaintenanceLoops(params: StartMaintenanceParams): void { setInterval(() => { const startedAt = Date.now(); @@ -82,8 +92,11 @@ export function startMaintenanceLoops(params: StartMaintenanceParams): void { } continue; } + if (!shouldSendHeartbeatPing(info, now, params.heartbeatIntervalMs)) { + continue; + } try { - sendPingRequest(info, info.ws, "heartbeat"); + sendPingRequest(info, info.ws, "heartbeat", params.heartbeatIntervalMs); } catch (err) { logger.debug(`[ping] heartbeat failed for ${id}`, err); } diff --git a/Overlord-Server/src/server/routes/client-command-routes.test.ts b/Overlord-Server/src/server/routes/client-command-routes.test.ts new file mode 100644 index 0000000..4a1750c --- /dev/null +++ b/Overlord-Server/src/server/routes/client-command-routes.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { decodeMessage } from "../../protocol"; +import type { ClientInfo } from "../../types"; +import { dispatchPingBulk } from "./client-command-routes"; + +function makeClient(): ClientInfo { + const sent: Uint8Array[] = []; + return { + id: "bulk-ping-client", + role: "client", + lastSeen: Date.now(), + ws: { + send(data: Uint8Array) { + sent.push(data); + }, + _sent: sent, + }, + } as any; +} + +describe("client command ping diagnostics", () => { + test("dispatchPingBulk sends the requested number of pings", () => { + const client = makeClient(); + const sent = (client.ws as any)._sent as Uint8Array[]; + + const count = dispatchPingBulk(client, 3); + + expect(count).toBe(3); + expect(sent.length).toBe(3); + for (const raw of sent) { + const payload = decodeMessage(raw) as any; + expect(payload.type).toBe("ping"); + expect(typeof payload.ts).toBe("number"); + } + expect(typeof client.lastPingNonce).toBe("number"); + }); + + test("dispatchPingBulk clamps invalid and large counts", () => { + const invalid = makeClient(); + expect(dispatchPingBulk(invalid, "bad")).toBe(1); + expect((invalid.ws as any)._sent.length).toBe(1); + + const large = makeClient(); + expect(dispatchPingBulk(large, 5000)).toBe(1000); + expect((large.ws as any)._sent.length).toBe(1000); + }); +}); diff --git a/Overlord-Server/src/server/routes/client-command-routes.ts b/Overlord-Server/src/server/routes/client-command-routes.ts index cbabaf0..2d3d852 100644 --- a/Overlord-Server/src/server/routes/client-command-routes.ts +++ b/Overlord-Server/src/server/routes/client-command-routes.ts @@ -7,6 +7,8 @@ import { metrics } from "../../metrics"; import { encodeMessage } from "../../protocol"; import { requireClientAccess, requireFeatureAccess, requirePermission } from "../../rbac"; import { clearThumbnail } from "../../thumbnails"; +import type { ClientInfo } from "../../types"; +import { sendPingRequest } from "../../wsHandlers"; type RequestIpProvider = { requestIP: (req: Request) => { address?: string } | null | undefined; @@ -32,6 +34,20 @@ type ClientCommandDeps = { pendingCommandReplies: Map; }; +function clampPositiveInt(value: unknown, fallback: number, max: number): number { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.min(max, Math.max(1, Math.floor(parsed))); +} + +export function dispatchPingBulk(target: ClientInfo, countValue: unknown): number { + const count = clampPositiveInt(countValue, 1, 1000); + for (let i = 0; i < count; i++) { + sendPingRequest(target, target.ws, "manual-bulk", 0); + } + return count; +} + export async function handleClientCommandRoute( req: Request, url: URL, @@ -71,12 +87,13 @@ export async function handleClientCommandRoute( let success = true; if (action === "ping") { - const nonce = Date.now() + Math.floor(Math.random() * 1000); - target.lastPingSent = Date.now(); - target.lastPingNonce = nonce; - target.ws.send(encodeMessage({ type: "ping", ts: nonce })); + sendPingRequest(target, target.ws, "manual", 0); + metrics.recordCommand("ping"); } else if (action === "ping_bulk") { - Math.max(1, Math.min(1000, Number(body?.count || 1))); + const count = dispatchPingBulk(target, body?.count); + metrics.recordCommand("ping_bulk"); + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, targetClientId: targetId, details: `ping_bulk:${count}`, success: true }); + return Response.json({ ok: true, sent: count }, { headers: deps.CORS_HEADERS }); } else if (action === "disconnect") { try { requirePermission(user, "clients:disconnect"); diff --git a/Overlord-Server/src/wsHandlers.test.ts b/Overlord-Server/src/wsHandlers.test.ts index 3838f60..8912223 100644 --- a/Overlord-Server/src/wsHandlers.test.ts +++ b/Overlord-Server/src/wsHandlers.test.ts @@ -30,6 +30,25 @@ describe("wsHandlers ping/pong", () => { expect(ws.sent.length).toBe(1); }); + test("sendPingRequest can bypass the interval for manual pings", () => { + const ws: MockWs = { + sent: [], + send(msg) { + this.sent.push(msg); + }, + }; + const info = { + id: "client-manual-ping", + role: "client", + ws, + lastSeen: Date.now(), + } as any; + + expect(sendPingRequest(info, ws, "test")).toBe(true); + expect(sendPingRequest(info, ws, "manual", 0)).toBe(true); + expect(ws.sent.length).toBe(2); + }); + test("handlePing responds with pong without starting another server ping", () => { const ws: MockWs = { sent: [], @@ -53,6 +72,50 @@ describe("wsHandlers ping/pong", () => { expect(info.lastPingNonce).toBeUndefined(); }); + test("handlePing preserves zero timestamps", () => { + const ws: MockWs = { + sent: [], + send(msg) { + this.sent.push(msg); + }, + }; + const info = { + id: "client-zero-ping", + role: "client", + ws, + lastSeen: Date.now(), + } as any; + + handlePing(info, { type: "ping", ts: 0 } as any, ws); + + const payload = decodeMessage(ws.sent[0]) as any; + expect(payload.type).toBe("pong"); + expect(payload.ts).toBe(0); + }); + + test("handlePing falls back for invalid timestamps", () => { + const ws: MockWs = { + sent: [], + send(msg) { + this.sent.push(msg); + }, + }; + const info = { + id: "client-invalid-ping", + role: "client", + ws, + lastSeen: Date.now(), + } as any; + const before = Date.now(); + + handlePing(info, { type: "ping", ts: "bad" } as any, ws); + + const payload = decodeMessage(ws.sent[0]) as any; + expect(payload.type).toBe("pong"); + expect(payload.ts).toBeGreaterThanOrEqual(before); + expect(payload.ts).toBeLessThanOrEqual(Date.now()); + }); + test("handlePong clears nonce and records ping", () => { metrics.reset(); const ws: MockWs = { diff --git a/Overlord-Server/src/wsHandlers.ts b/Overlord-Server/src/wsHandlers.ts index 9f61bf2..5dcf212 100644 --- a/Overlord-Server/src/wsHandlers.ts +++ b/Overlord-Server/src/wsHandlers.ts @@ -171,19 +171,28 @@ export function handlePing(info: ClientInfo, payload: Ping, ws: any) { isAdmin: info.isAdmin, }); } - ws.send(encodeMessage({ type: "pong", ts: payload.ts || Date.now() })); + const ts = typeof payload.ts === "number" && Number.isFinite(payload.ts) + ? payload.ts + : Date.now(); + ws.send(encodeMessage({ type: "pong", ts })); } -export function sendPingRequest(info: ClientInfo, ws: any, reason: string) { +export function sendPingRequest( + info: ClientInfo, + ws: any, + reason: string, + minIntervalMs = 1_000, +): boolean { const now = Date.now(); - if (info.lastPingSent && now - info.lastPingSent < 1_000) { - return; + if (minIntervalMs > 0 && info.lastPingSent && now - info.lastPingSent < minIntervalMs) { + return false; } const nonce = now + Math.floor(Math.random() * 1000); info.lastPingSent = now; info.lastPingNonce = nonce; //console.log(`[ping] send ping to client=${info.id} reason=${reason} nonce=${nonce}`); ws.send(encodeMessage({ type: "ping", ts: nonce })); + return true; } export function handlePong(info: ClientInfo, payload: WireMessage) {