From f97f35de1f598b94a89e2e3134abf063b05e3e36 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 30 Sep 2026 17:49:09 -0500 Subject: [PATCH] fix: count plugin ssh logins toward host status and stop showing reachable as a warning --- .../src/frontend/widgets/HostGridWidget.tsx | 2 +- .../src/frontend/widgets/HostStatusWidget.tsx | 2 +- .../src/frontend/NetworkGraphCard.tsx | 4 +- .../hosts/status/host-status-service.ts | 40 +++++++++++----- src/backend/hosts/status/host-status.ts | 4 +- src/backend/plugins/ctx-ssh-auth.ts | 13 +++++ .../hosts/status/host-status-service.test.ts | 48 ++++++++++++++++++- .../tests/hosts/status/host-status.test.ts | 7 +-- .../tests/plugins/ctx-ssh-auth.test.ts | 21 ++++++++ src/ui/hooks/use-status-color-scheme.ts | 13 +++-- src/ui/locales/en.json | 2 +- .../hooks/use-status-color-scheme.test.ts | 12 +++++ 12 files changed, 143 insertions(+), 25 deletions(-) diff --git a/plugins/homepage/src/frontend/widgets/HostGridWidget.tsx b/plugins/homepage/src/frontend/widgets/HostGridWidget.tsx index 0a5e71372..333732890 100644 --- a/plugins/homepage/src/frontend/widgets/HostGridWidget.tsx +++ b/plugins/homepage/src/frontend/widgets/HostGridWidget.tsx @@ -24,7 +24,7 @@ function StatusDot({ status }: { status: string }) { status === "online" ? getAccentColor() : status === "reachable" - ? "#fbbf24" + ? `color-mix(in srgb, ${getAccentColor()} 40%, transparent)` : status === "offline" ? "#ef4444" : "#6b7280"; diff --git a/plugins/homepage/src/frontend/widgets/HostStatusWidget.tsx b/plugins/homepage/src/frontend/widgets/HostStatusWidget.tsx index 2438168f8..3d7091ca2 100644 --- a/plugins/homepage/src/frontend/widgets/HostStatusWidget.tsx +++ b/plugins/homepage/src/frontend/widgets/HostStatusWidget.tsx @@ -60,7 +60,7 @@ function HostStatusWidget({ : online ? getAccentColor() : reachable - ? "#fbbf24" + ? `color-mix(in srgb, ${getAccentColor()} 40%, transparent)` : "#ef4444"; const onlineLabel = !statusInfo ? t("common.unknown") diff --git a/plugins/network-topology/src/frontend/NetworkGraphCard.tsx b/plugins/network-topology/src/frontend/NetworkGraphCard.tsx index d98a610e5..c9db14bea 100644 --- a/plugins/network-topology/src/frontend/NetworkGraphCard.tsx +++ b/plugins/network-topology/src/frontend/NetworkGraphCard.tsx @@ -142,7 +142,9 @@ function buildNodeSvg( ? "rgb(16,185,129)" : resolveCssVar("--accent-brand", "rgb(16,185,129)"); } else if (status === "reachable") { - statusColor = "rgb(251,191,36)"; + statusColor = useRealColors + ? "rgba(16,185,129,0.4)" + : `color-mix(in srgb, ${resolveCssVar("--accent-brand", "rgb(16,185,129)")} 40%, transparent)`; } else if (isOffline) { statusColor = useRealColors ? "rgb(239,68,68)" : "rgba(16,185,129,0.2)"; } else { diff --git a/src/backend/hosts/status/host-status-service.ts b/src/backend/hosts/status/host-status-service.ts index f3fd30c07..214114019 100644 --- a/src/backend/hosts/status/host-status-service.ts +++ b/src/backend/hosts/status/host-status-service.ts @@ -3,9 +3,11 @@ * * Every host with status checks on gets a TCP connection to its port on a * timer. That alone says "reachable". A host becomes "online" when a login - * worked: an open terminal session, or a plugin reporting one through - * ctx.hosts.status.reportLogin. Probing never logs in: on RADIUS or Duo backed - * devices a login fires a real 2FA push, every interval. + * worked: an open terminal session, any plugin connection through ctx.ssh, or + * a plugin reporting one through ctx.hosts.status.reportLogin. It stays online + * for LOGIN_FRESH_MS after the last login while the port still answers. + * Probing never logs in: on RADIUS or Duo backed devices a login fires a real + * 2FA push, every interval. * * Polling is demand driven. A user asking for statuses starts polling their * own hosts; nothing is probed for a user who never opened the app. @@ -31,6 +33,8 @@ export const GLOBAL_STATUS_INTERVAL_KEY = "global_status_check_interval"; export const DEFAULT_STATUS_INTERVAL = 60; /** A status younger than this is fresh enough for check(). */ const FRESH_MS = 30_000; +/** How long a successful login keeps a reachable host online. */ +export const LOGIN_FRESH_MS = 60 * 60_000; export interface HostStatusEntry { status: HostStatus; @@ -77,6 +81,7 @@ export interface HostStatusDeps { ) => Promise; globalInterval: () => number; emit: (payload: HostStatusPayload) => void; + now?: () => number; } function parseJumpHosts(raw: string | null): Array<{ hostId: number }> { @@ -154,7 +159,7 @@ export class HostStatusService { private readonly owners = new Map(); private readonly inFlight = new Map>(); private readonly sessionOnline = new Set(); - private readonly loggedIn = new Set(); + private readonly lastLogin = new Map(); private readonly startedUsers = new Set(); private readonly portResolvers = new Map>(); private readonly limiter = new ConcurrentLimiter(20); @@ -247,11 +252,11 @@ export class HostStatusService { outcome: { ok: boolean; hostKeyChanged?: boolean }, ): void { if (outcome.ok) { - this.loggedIn.add(hostId); + this.lastLogin.set(hostId, this.now()); this.set(hostId, { status: statusAfterAuthentication(true) }); return; } - this.loggedIn.delete(hostId); + this.lastLogin.delete(hostId); this.set(hostId, { status: this.sessionOnline.has(hostId) ? "online" @@ -346,7 +351,7 @@ export class HostStatusService { this.stopPolling(hostId); this.store.delete(hostId); this.owners.delete(hostId); - this.loggedIn.delete(hostId); + this.lastLogin.delete(hostId); } private clearHostKeyReason(hostId: number): void { @@ -361,18 +366,29 @@ export class HostStatusService { private setSessionOnline(hostId: number, online: boolean): void { if (online) { this.sessionOnline.add(hostId); + this.lastLogin.set(hostId, this.now()); if (this.polled.has(hostId)) this.set(hostId, { status: "online" }); return; } this.sessionOnline.delete(hostId); if ( - !this.loggedIn.has(hostId) && + !this.isLoggedIn(hostId) && this.store.get(hostId)?.status === "online" ) { this.set(hostId, { status: "reachable" }); } } + private now(): number { + return this.deps.now?.() ?? Date.now(); + } + + private isLoggedIn(hostId: number): boolean { + if (this.sessionOnline.has(hostId)) return true; + const at = this.lastLogin.get(hostId); + return at !== undefined && this.now() - at < LOGIN_FRESH_MS; + } + private intervalMs(target: StatusTarget): number { const seconds = target.statusCheckInterval ?? this.deps.globalInterval(); const intervalMs = Math.max(5, seconds) * 1000; @@ -452,10 +468,10 @@ export class HostStatusService { const current = this.store.get(target.id); this.set(target.id, { - status: - reachable && this.sessionOnline.has(target.id) - ? "online" - : statusAfterReachabilityCheck(reachable, current?.status), + status: statusAfterReachabilityCheck( + reachable, + this.isLoggedIn(target.id), + ), ...(reachable && current?.reason === "host_key_changed" ? { reason: "host_key_changed" as const } : {}), diff --git a/src/backend/hosts/status/host-status.ts b/src/backend/hosts/status/host-status.ts index 066db1f97..bc5772e83 100644 --- a/src/backend/hosts/status/host-status.ts +++ b/src/backend/hosts/status/host-status.ts @@ -10,10 +10,10 @@ export function isHostKeyVerificationError(error: unknown): boolean { export function statusAfterReachabilityCheck( reachable: boolean, - current?: HostStatus, + loggedIn: boolean, ): HostStatus { if (!reachable) return "offline"; - return current === "online" ? "online" : "reachable"; + return loggedIn ? "online" : "reachable"; } export function statusAfterAuthentication( diff --git a/src/backend/plugins/ctx-ssh-auth.ts b/src/backend/plugins/ctx-ssh-auth.ts index 2da73c815..4449ce599 100644 --- a/src/backend/plugins/ctx-ssh-auth.ts +++ b/src/backend/plugins/ctx-ssh-auth.ts @@ -164,6 +164,18 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh { return { ...(host as unknown as SshConnectHost), userId }; }; + /** A login to a saved host core resolved counts toward its status dot. */ + const reportLogin = (host: number | PluginSshHost) => { + const hostId = + typeof host === "number" ? host : resolvedHosts.get(host)?.host.id; + if (!Number.isInteger(hostId) || hostId <= 0) return; + void import("../hosts/status/host-status-service.js") + .then(({ hostStatusService }) => + hostStatusService.reportLogin(hostId, { ok: true }), + ) + .catch(() => {}); + }; + /** Checks, connects and audits one new connection. */ const connectOnce = async ( host: number | PluginSshHost, @@ -201,6 +213,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh { sock: options?.sock as MutableConnectConfig["sock"], }); await audit("ssh_connect", describeHost(host), { success: true }); + reportLogin(host); const dispose = () => { open.delete(dispose); diff --git a/src/backend/tests/hosts/status/host-status-service.test.ts b/src/backend/tests/hosts/status/host-status-service.test.ts index 956df60e1..04bb1c9f7 100644 --- a/src/backend/tests/hosts/status/host-status-service.test.ts +++ b/src/backend/tests/hosts/status/host-status-service.test.ts @@ -9,7 +9,7 @@ vi.mock("../../../utils/logger.js", () => ({ pluginLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); -const { HostStatusService, toStatusTarget } = +const { HostStatusService, toStatusTarget, LOGIN_FRESH_MS } = await import("../../../hosts/status/host-status-service.js"); const { hostSessionStatus } = await import("../../../hosts/host-session-status.js"); @@ -35,6 +35,7 @@ function setup( targets: Target[], reachable = true, shared: Record = {}, + clock = { now: 1_000_000 }, ) { const emitted: unknown[] = []; const ping = vi.fn(async () => reachable); @@ -54,6 +55,7 @@ function setup( pingThroughJumpHosts, globalInterval: () => 60, emit: (payload) => emitted.push(payload), + now: () => clock.now, }); return { service, emitted, ping, pingThroughJumpHosts, loadTargets }; } @@ -206,9 +208,53 @@ describe("HostStatusService", () => { const release = hostSessionStatus.register(1); expect(service.get(1)?.status).toBe("online"); release(); + expect(service.get(1)?.status).toBe("online"); + }); + + it("keeps a host online for a while after its last login", async () => { + const clock = { now: 1_000_000 }; + const { service } = setup([target(1)], true, {}, clock); + active = service; + await service.statusesFor("owner", null); + await flush(); + + service.reportLogin(1, { ok: true }); + clock.now += LOGIN_FRESH_MS - 1; + vi.advanceTimersByTime(75_000); + await flush(); + expect(service.get(1)?.status).toBe("online"); + + clock.now += 2; + vi.advanceTimersByTime(75_000); + await flush(); expect(service.get(1)?.status).toBe("reachable"); }); + it("drops an ended session back to reachable once the login is stale", async () => { + const clock = { now: 1_000_000 }; + const { service } = setup([target(1)], true, {}, clock); + active = service; + service.start(); + await service.statusesFor("owner", null); + await flush(); + + const release = hostSessionStatus.register(1); + clock.now += LOGIN_FRESH_MS; + release(); + expect(service.get(1)?.status).toBe("reachable"); + }); + + it("shows an offline host as offline even after a login", async () => { + const { service } = setup([target(1)], false); + active = service; + await service.statusesFor("owner", null); + await flush(); + service.reportLogin(1, { ok: true }); + vi.advanceTimersByTime(75_000); + await flush(); + expect(service.get(1)?.status).toBe("offline"); + }); + it("only checks the requested hosts on the desktop app", async () => { const { service, ping } = setup([target(1), target(2)]); active = service; diff --git a/src/backend/tests/hosts/status/host-status.test.ts b/src/backend/tests/hosts/status/host-status.test.ts index b12206346..b23a64994 100644 --- a/src/backend/tests/hosts/status/host-status.test.ts +++ b/src/backend/tests/hosts/status/host-status.test.ts @@ -22,11 +22,12 @@ describe("isHostKeyVerificationError", () => { describe("host availability status", () => { it("does not call a TCP-reachable host online before authentication", () => { - expect(statusAfterReachabilityCheck(true)).toBe("reachable"); + expect(statusAfterReachabilityCheck(true, false)).toBe("reachable"); }); - it("keeps a verified host online across later reachability checks", () => { - expect(statusAfterReachabilityCheck(true, "online")).toBe("online"); + it("keeps a recently verified host online across reachability checks", () => { + expect(statusAfterReachabilityCheck(true, true)).toBe("online"); + expect(statusAfterReachabilityCheck(false, true)).toBe("offline"); }); it("marks successful authentication online", () => { diff --git a/src/backend/tests/plugins/ctx-ssh-auth.test.ts b/src/backend/tests/plugins/ctx-ssh-auth.test.ts index 143de5008..4cc71b28c 100644 --- a/src/backend/tests/plugins/ctx-ssh-auth.test.ts +++ b/src/backend/tests/plugins/ctx-ssh-auth.test.ts @@ -13,6 +13,7 @@ const h = vi.hoisted(() => ({ revoked: [] as Array<{ userId: string; except?: string }>, trustedCleared: [] as string[], policyError: null as Error | null, + logins: [] as Array<{ hostId: number; outcome: unknown }>, })); vi.mock("../../utils/logger.js", () => { @@ -88,6 +89,12 @@ vi.mock("../../hosts/connect/connect-host.js", () => ({ return { client, jumpClient: null, dispose: vi.fn(() => client.end()) }; }, })); +vi.mock("../../hosts/status/host-status-service.js", () => ({ + hostStatusService: { + reportLogin: (hostId: number, outcome: unknown) => + h.logins.push({ hostId, outcome }), + }, +})); vi.mock("../../database/repositories/factory.js", () => ({ createCurrentUserAuthRepository: () => ({ recordSecondFactor: async ( @@ -141,6 +148,7 @@ beforeEach(() => { h.cleared = []; h.clearedAll = 0; h.pooled = []; + h.logins = []; }); describe("ctx.ssh", () => { @@ -184,6 +192,19 @@ describe("ctx.ssh", () => { }); }); + it("reports a login to a saved host's status, never to a plugin's own host", async () => { + h.granted = new Set(["ssh:connect", "credentials:use"]); + const ssh = createPluginSsh({ + manifest: manifest(["ssh:connect", "credentials:use"]), + bag: new DisposableBag("fixture"), + audit: vi.fn(async () => {}), + }); + await ssh.connect(7); + await ssh.connect({ id: 9, ip: "h", port: 22, username: "u" } as never); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(h.logins).toEqual([{ hostId: 7, outcome: { ok: true } }]); + }); + it("never believes a host object's own userId", async () => { h.actor = undefined; h.granted = new Set(["ssh:connect", "credentials:use"]); diff --git a/src/ui/hooks/use-status-color-scheme.ts b/src/ui/hooks/use-status-color-scheme.ts index 0af87ec8d..3b3884e79 100644 --- a/src/ui/hooks/use-status-color-scheme.ts +++ b/src/ui/hooks/use-status-color-scheme.ts @@ -65,10 +65,17 @@ export function getStatusClasses( if (variant === "stripe") return "bg-muted-foreground/20 animate-pulse"; return "border-border/50 text-muted-foreground/50 bg-muted/20 animate-pulse"; } + // Reachable is a faded online, not a warning: the port answers, nobody has + // logged in lately. if (reachable) { - if (variant === "dot") return "bg-amber-400"; - if (variant === "stripe") return "bg-amber-400/50"; - return "border-amber-400/40 text-amber-400 bg-amber-400/10"; + if (scheme === "status") { + if (variant === "dot") return "bg-emerald-500/40"; + if (variant === "stripe") return "bg-emerald-500/30"; + return "border-emerald-500/30 text-emerald-500/70 bg-emerald-500/5"; + } + if (variant === "dot") return "bg-accent-brand/40"; + if (variant === "stripe") return "bg-accent-brand/30"; + return "border-accent-brand/30 text-accent-brand/70 bg-accent-brand/5"; } if (scheme === "status") { if (variant === "dot") return online ? "bg-emerald-500" : "bg-red-500"; diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 6721dbca9..006608c94 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -349,7 +349,7 @@ "serverStats": "Host Metrics", "status": { "available": "Available", - "reachable": "Reachable, not authenticated", + "reachable": "Up, not signed in lately", "offline": "Offline", "monitoringDisabled": "Monitoring disabled", "label": "Status" diff --git a/src/ui/tests/hooks/use-status-color-scheme.test.ts b/src/ui/tests/hooks/use-status-color-scheme.test.ts index e719c6790..7c7d2337f 100644 --- a/src/ui/tests/hooks/use-status-color-scheme.test.ts +++ b/src/ui/tests/hooks/use-status-color-scheme.test.ts @@ -65,4 +65,16 @@ describe("getStatusClasses", () => { it("uses the accent color for the accent scheme", () => { expect(getStatusClasses(true, "accent", "dot")).toContain("accent-brand"); }); + + it("draws reachable as a faded online, never a warning color", () => { + expect(getStatusClasses("reachable", "accent", "dot")).toBe( + "bg-accent-brand/40", + ); + expect(getStatusClasses("reachable", "status", "dot")).toBe( + "bg-emerald-500/40", + ); + expect(getStatusClasses("reachable", "status", "badge")).not.toMatch( + /amber|yellow|red/, + ); + }); });