fix: count plugin ssh logins toward host status and stop showing reachable as a warning

This commit is contained in:
LukeGus committed 2026-09-30 17:49:09 -05:00
1 parent 69b1b9805b
commit f97f35de1f
12 files changed
+143 -25

No files matched your search

@@ -24,7 +24,7 @@ function StatusDot({ status }: { status: string }) {
status === "online" status === "online"
? getAccentColor() ? getAccentColor()
: status === "reachable" : status === "reachable"
? "#fbbf24" ? `color-mix(in srgb, ${getAccentColor()} 40%, transparent)`
: status === "offline" : status === "offline"
? "#ef4444" ? "#ef4444"
: "#6b7280"; : "#6b7280";
@@ -60,7 +60,7 @@ function HostStatusWidget({
: online : online
? getAccentColor() ? getAccentColor()
: reachable : reachable
? "#fbbf24" ? `color-mix(in srgb, ${getAccentColor()} 40%, transparent)`
: "#ef4444"; : "#ef4444";
const onlineLabel = !statusInfo const onlineLabel = !statusInfo
? t("common.unknown") ? t("common.unknown")
@@ -142,7 +142,9 @@ function buildNodeSvg(
? "rgb(16,185,129)" ? "rgb(16,185,129)"
: resolveCssVar("--accent-brand", "rgb(16,185,129)"); : resolveCssVar("--accent-brand", "rgb(16,185,129)");
} else if (status === "reachable") { } 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) { } else if (isOffline) {
statusColor = useRealColors ? "rgb(239,68,68)" : "rgba(16,185,129,0.2)"; statusColor = useRealColors ? "rgb(239,68,68)" : "rgba(16,185,129,0.2)";
} else { } else {
+28 -12
View File
@@ -3,9 +3,11 @@
* *
* Every host with status checks on gets a TCP connection to its port on a * 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 * timer. That alone says "reachable". A host becomes "online" when a login
* worked: an open terminal session, or a plugin reporting one through * worked: an open terminal session, any plugin connection through ctx.ssh, or
* ctx.hosts.status.reportLogin. Probing never logs in: on RADIUS or Duo backed * a plugin reporting one through ctx.hosts.status.reportLogin. It stays online
* devices a login fires a real 2FA push, every interval. * 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 * 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. * 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; export const DEFAULT_STATUS_INTERVAL = 60;
/** A status younger than this is fresh enough for check(). */ /** A status younger than this is fresh enough for check(). */
const FRESH_MS = 30_000; 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 { export interface HostStatusEntry {
status: HostStatus; status: HostStatus;
@@ -77,6 +81,7 @@ export interface HostStatusDeps {
) => Promise<boolean>; ) => Promise<boolean>;
globalInterval: () => number; globalInterval: () => number;
emit: (payload: HostStatusPayload) => void; emit: (payload: HostStatusPayload) => void;
now?: () => number;
} }
function parseJumpHosts(raw: string | null): Array<{ hostId: number }> { function parseJumpHosts(raw: string | null): Array<{ hostId: number }> {
@@ -154,7 +159,7 @@ export class HostStatusService {
private readonly owners = new Map<number, string>(); private readonly owners = new Map<number, string>();
private readonly inFlight = new Map<number, Promise<void>>(); private readonly inFlight = new Map<number, Promise<void>>();
private readonly sessionOnline = new Set<number>(); private readonly sessionOnline = new Set<number>();
private readonly loggedIn = new Set<number>(); private readonly lastLogin = new Map<number, number>();
private readonly startedUsers = new Set<string>(); private readonly startedUsers = new Set<string>();
private readonly portResolvers = new Map<string, Set<PortResolver>>(); private readonly portResolvers = new Map<string, Set<PortResolver>>();
private readonly limiter = new ConcurrentLimiter(20); private readonly limiter = new ConcurrentLimiter(20);
@@ -247,11 +252,11 @@ export class HostStatusService {
outcome: { ok: boolean; hostKeyChanged?: boolean }, outcome: { ok: boolean; hostKeyChanged?: boolean },
): void { ): void {
if (outcome.ok) { if (outcome.ok) {
this.loggedIn.add(hostId); this.lastLogin.set(hostId, this.now());
this.set(hostId, { status: statusAfterAuthentication(true) }); this.set(hostId, { status: statusAfterAuthentication(true) });
return; return;
} }
this.loggedIn.delete(hostId); this.lastLogin.delete(hostId);
this.set(hostId, { this.set(hostId, {
status: this.sessionOnline.has(hostId) status: this.sessionOnline.has(hostId)
? "online" ? "online"
@@ -346,7 +351,7 @@ export class HostStatusService {
this.stopPolling(hostId); this.stopPolling(hostId);
this.store.delete(hostId); this.store.delete(hostId);
this.owners.delete(hostId); this.owners.delete(hostId);
this.loggedIn.delete(hostId); this.lastLogin.delete(hostId);
} }
private clearHostKeyReason(hostId: number): void { private clearHostKeyReason(hostId: number): void {
@@ -361,18 +366,29 @@ export class HostStatusService {
private setSessionOnline(hostId: number, online: boolean): void { private setSessionOnline(hostId: number, online: boolean): void {
if (online) { if (online) {
this.sessionOnline.add(hostId); this.sessionOnline.add(hostId);
this.lastLogin.set(hostId, this.now());
if (this.polled.has(hostId)) this.set(hostId, { status: "online" }); if (this.polled.has(hostId)) this.set(hostId, { status: "online" });
return; return;
} }
this.sessionOnline.delete(hostId); this.sessionOnline.delete(hostId);
if ( if (
!this.loggedIn.has(hostId) && !this.isLoggedIn(hostId) &&
this.store.get(hostId)?.status === "online" this.store.get(hostId)?.status === "online"
) { ) {
this.set(hostId, { status: "reachable" }); 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 { private intervalMs(target: StatusTarget): number {
const seconds = target.statusCheckInterval ?? this.deps.globalInterval(); const seconds = target.statusCheckInterval ?? this.deps.globalInterval();
const intervalMs = Math.max(5, seconds) * 1000; const intervalMs = Math.max(5, seconds) * 1000;
@@ -452,10 +468,10 @@ export class HostStatusService {
const current = this.store.get(target.id); const current = this.store.get(target.id);
this.set(target.id, { this.set(target.id, {
status: status: statusAfterReachabilityCheck(
reachable && this.sessionOnline.has(target.id) reachable,
? "online" this.isLoggedIn(target.id),
: statusAfterReachabilityCheck(reachable, current?.status), ),
...(reachable && current?.reason === "host_key_changed" ...(reachable && current?.reason === "host_key_changed"
? { reason: "host_key_changed" as const } ? { reason: "host_key_changed" as const }
: {}), : {}),
+2 -2
View File
@@ -10,10 +10,10 @@ export function isHostKeyVerificationError(error: unknown): boolean {
export function statusAfterReachabilityCheck( export function statusAfterReachabilityCheck(
reachable: boolean, reachable: boolean,
current?: HostStatus, loggedIn: boolean,
): HostStatus { ): HostStatus {
if (!reachable) return "offline"; if (!reachable) return "offline";
return current === "online" ? "online" : "reachable"; return loggedIn ? "online" : "reachable";
} }
export function statusAfterAuthentication( export function statusAfterAuthentication(
+13
View File
@@ -164,6 +164,18 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
return { ...(host as unknown as SshConnectHost), userId }; 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. */ /** Checks, connects and audits one new connection. */
const connectOnce = async ( const connectOnce = async (
host: number | PluginSshHost, host: number | PluginSshHost,
@@ -201,6 +213,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
sock: options?.sock as MutableConnectConfig["sock"], sock: options?.sock as MutableConnectConfig["sock"],
}); });
await audit("ssh_connect", describeHost(host), { success: true }); await audit("ssh_connect", describeHost(host), { success: true });
reportLogin(host);
const dispose = () => { const dispose = () => {
open.delete(dispose); open.delete(dispose);
@@ -9,7 +9,7 @@ vi.mock("../../../utils/logger.js", () => ({
pluginLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, 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"); await import("../../../hosts/status/host-status-service.js");
const { hostSessionStatus } = const { hostSessionStatus } =
await import("../../../hosts/host-session-status.js"); await import("../../../hosts/host-session-status.js");
@@ -35,6 +35,7 @@ function setup(
targets: Target[], targets: Target[],
reachable = true, reachable = true,
shared: Record<string, number[]> = {}, shared: Record<string, number[]> = {},
clock = { now: 1_000_000 },
) { ) {
const emitted: unknown[] = []; const emitted: unknown[] = [];
const ping = vi.fn(async () => reachable); const ping = vi.fn(async () => reachable);
@@ -54,6 +55,7 @@ function setup(
pingThroughJumpHosts, pingThroughJumpHosts,
globalInterval: () => 60, globalInterval: () => 60,
emit: (payload) => emitted.push(payload), emit: (payload) => emitted.push(payload),
now: () => clock.now,
}); });
return { service, emitted, ping, pingThroughJumpHosts, loadTargets }; return { service, emitted, ping, pingThroughJumpHosts, loadTargets };
} }
@@ -206,9 +208,53 @@ describe("HostStatusService", () => {
const release = hostSessionStatus.register(1); const release = hostSessionStatus.register(1);
expect(service.get(1)?.status).toBe("online"); expect(service.get(1)?.status).toBe("online");
release(); 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"); 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 () => { it("only checks the requested hosts on the desktop app", async () => {
const { service, ping } = setup([target(1), target(2)]); const { service, ping } = setup([target(1), target(2)]);
active = service; active = service;
@@ -22,11 +22,12 @@ describe("isHostKeyVerificationError", () => {
describe("host availability status", () => { describe("host availability status", () => {
it("does not call a TCP-reachable host online before authentication", () => { 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", () => { it("keeps a recently verified host online across reachability checks", () => {
expect(statusAfterReachabilityCheck(true, "online")).toBe("online"); expect(statusAfterReachabilityCheck(true, true)).toBe("online");
expect(statusAfterReachabilityCheck(false, true)).toBe("offline");
}); });
it("marks successful authentication online", () => { it("marks successful authentication online", () => {
@@ -13,6 +13,7 @@ const h = vi.hoisted(() => ({
revoked: [] as Array<{ userId: string; except?: string }>, revoked: [] as Array<{ userId: string; except?: string }>,
trustedCleared: [] as string[], trustedCleared: [] as string[],
policyError: null as Error | null, policyError: null as Error | null,
logins: [] as Array<{ hostId: number; outcome: unknown }>,
})); }));
vi.mock("../../utils/logger.js", () => { 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()) }; 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", () => ({ vi.mock("../../database/repositories/factory.js", () => ({
createCurrentUserAuthRepository: () => ({ createCurrentUserAuthRepository: () => ({
recordSecondFactor: async ( recordSecondFactor: async (
@@ -141,6 +148,7 @@ beforeEach(() => {
h.cleared = []; h.cleared = [];
h.clearedAll = 0; h.clearedAll = 0;
h.pooled = []; h.pooled = [];
h.logins = [];
}); });
describe("ctx.ssh", () => { 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 () => { it("never believes a host object's own userId", async () => {
h.actor = undefined; h.actor = undefined;
h.granted = new Set(["ssh:connect", "credentials:use"]); h.granted = new Set(["ssh:connect", "credentials:use"]);
+10 -3
View File
@@ -65,10 +65,17 @@ export function getStatusClasses(
if (variant === "stripe") return "bg-muted-foreground/20 animate-pulse"; if (variant === "stripe") return "bg-muted-foreground/20 animate-pulse";
return "border-border/50 text-muted-foreground/50 bg-muted/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 (reachable) {
if (variant === "dot") return "bg-amber-400"; if (scheme === "status") {
if (variant === "stripe") return "bg-amber-400/50"; if (variant === "dot") return "bg-emerald-500/40";
return "border-amber-400/40 text-amber-400 bg-amber-400/10"; 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 (scheme === "status") {
if (variant === "dot") return online ? "bg-emerald-500" : "bg-red-500"; if (variant === "dot") return online ? "bg-emerald-500" : "bg-red-500";
+1 -1
View File
@@ -349,7 +349,7 @@
"serverStats": "Host Metrics", "serverStats": "Host Metrics",
"status": { "status": {
"available": "Available", "available": "Available",
"reachable": "Reachable, not authenticated", "reachable": "Up, not signed in lately",
"offline": "Offline", "offline": "Offline",
"monitoringDisabled": "Monitoring disabled", "monitoringDisabled": "Monitoring disabled",
"label": "Status" "label": "Status"
@@ -65,4 +65,16 @@ describe("getStatusClasses", () => {
it("uses the accent color for the accent scheme", () => { it("uses the accent color for the accent scheme", () => {
expect(getStatusClasses(true, "accent", "dot")).toContain("accent-brand"); 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/,
);
});
}); });