diff --git a/src/backend/hosts/host-session-status.ts b/src/backend/hosts/host-session-status.ts index b9406c584..dd348419f 100644 --- a/src/backend/hosts/host-session-status.ts +++ b/src/backend/hosts/host-session-status.ts @@ -11,6 +11,10 @@ export class HostSessionStatus { private counts = new Map(); private listeners = new Set(); + hasActiveSession(hostId: number): boolean { + return (this.counts.get(hostId) ?? 0) > 0; + } + register(hostId: number): () => void { const count = this.counts.get(hostId) ?? 0; this.counts.set(hostId, count + 1); diff --git a/src/backend/hosts/status/host-status-service.ts b/src/backend/hosts/status/host-status-service.ts index 03b9fadd1..ddea258a2 100644 --- a/src/backend/hosts/status/host-status-service.ts +++ b/src/backend/hosts/status/host-status-service.ts @@ -78,6 +78,7 @@ export interface HostStatusDeps { target: StatusTarget, port: number, ) => Promise; + hasActiveSession?: (hostId: number) => boolean; globalInterval: () => number; emit: (payload: HostStatusPayload) => void; } @@ -126,6 +127,7 @@ const defaultDeps: HostStatusDeps = { ); return [...new Set(entries.map((entry) => entry.hostId))]; }, + hasActiveSession: (hostId) => hostSessionStatus.hasActiveSession(hostId), ping: (host, port) => tcpPing(host, port, 5000), pingThroughJumpHosts: async (target, port) => { const { createJumpHostChain } = await import("../jump-host-chain.js"); @@ -429,11 +431,15 @@ export class HostStatusService { this.owners.set(target.id, target.userId); let reachable = false; try { - const port = await this.portFor(target); - reachable = - target.jumpHosts.length > 0 - ? await this.deps.pingThroughJumpHosts(target, port) - : await this.deps.ping(target.ip, port); + if (this.deps.hasActiveSession?.(target.id)) { + reachable = true; + } else { + const port = await this.portFor(target); + reachable = + target.jumpHosts.length > 0 + ? await this.deps.pingThroughJumpHosts(target, port) + : await this.deps.ping(target.ip, port); + } } catch { reachable = false; } diff --git a/src/backend/hosts/status/tcp-ping.ts b/src/backend/hosts/status/tcp-ping.ts index f060a798e..807663606 100644 --- a/src/backend/hosts/status/tcp-ping.ts +++ b/src/backend/hosts/status/tcp-ping.ts @@ -2,8 +2,8 @@ import net from "net"; import type { Client } from "ssh2"; /** - * Opens a TCP connection and closes it again. An SSH server gets a polite - * banner back so it does not log a failed handshake. + * Opens a TCP connection and closes it again. SSH probes exchange banners, + * but still close before authentication and may trigger aggressive Fail2Ban rules. */ export function tcpPing( host: string, diff --git a/src/backend/tests/hosts/host-session-status.test.ts b/src/backend/tests/hosts/host-session-status.test.ts index d541d7a76..bcb21de6d 100644 --- a/src/backend/tests/hosts/host-session-status.test.ts +++ b/src/backend/tests/hosts/host-session-status.test.ts @@ -13,10 +13,13 @@ describe("HostSessionStatus", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenLastCalledWith(7, true); + expect(status.hasActiveSession(7)).toBe(true); closeFirst(); + expect(status.hasActiveSession(7)).toBe(true); expect(listener).toHaveBeenCalledTimes(1); closeSecond(); + expect(status.hasActiveSession(7)).toBe(false); expect(listener).toHaveBeenLastCalledWith(7, false); expect(listener).toHaveBeenCalledTimes(2); }); 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 a02cda9ef..c5aa28214 100644 --- a/src/backend/tests/hosts/status/host-status-service.test.ts +++ b/src/backend/tests/hosts/status/host-status-service.test.ts @@ -52,6 +52,7 @@ function setup( loadSharedHostIds: async (userId) => shared[userId] ?? [], ping, pingThroughJumpHosts, + hasActiveSession: (id) => hostSessionStatus.hasActiveSession(id), globalInterval: () => 60, emit: (payload) => emitted.push(payload), }); @@ -74,6 +75,34 @@ afterEach(() => { }); describe("HostStatusService", () => { + it.each([{ jumpHosts: [] }, { jumpHosts: [{ hostId: 9 }] }])( + "skips probes while a session is open, including jump hosts: %j", + async ({ jumpHosts }) => { + const { service, ping, pingThroughJumpHosts } = setup([ + target(7, { jumpHosts }), + ]); + active = service; + const close = hostSessionStatus.register(7); + try { + await service.statusesFor("owner", null); + await flush(); + await vi.advanceTimersByTimeAsync(60_000); + await flush(); + expect(service.get(7)?.status).toBe("online"); + expect(ping).not.toHaveBeenCalled(); + expect(pingThroughJumpHosts).not.toHaveBeenCalled(); + close(); + await vi.advanceTimersByTimeAsync(60_000); + await flush(); + expect( + jumpHosts.length ? pingThroughJumpHosts : ping, + ).toHaveBeenCalledOnce(); + } finally { + close(); + } + }, + ); + it("starts a user's own hosts and reports them online", async () => { const { service, emitted, ping } = setup([target(1), target(2)]); active = service; diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 7749192ba..a19249f17 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -479,7 +479,7 @@ "sameHost": "This host (direct tunnel)", "statusChecksLabel": "Status Checks", "enableStatusChecks": "Enable Status Checks", - "enableStatusChecksDesc": "Periodically ping this host to verify availability", + "enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.", "useGlobalInterval": "Use Global Interval", "useGlobalIntervalDesc": "Override with the server-wide status check interval", "checkIntervalS": "Check Interval (s)",