fix(status): skip TCP probes while host sessions are active (#1538)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-10-03 21:01:12 +08:00
1 parent bd0858b5a2
commit dc5d57c012
6 files changed
+50 -8

No files matched your search

+4
View File
@@ -11,6 +11,10 @@ export class HostSessionStatus {
private counts = new Map<number, number>(); private counts = new Map<number, number>();
private listeners = new Set<HostSessionStatusListener>(); private listeners = new Set<HostSessionStatusListener>();
hasActiveSession(hostId: number): boolean {
return (this.counts.get(hostId) ?? 0) > 0;
}
register(hostId: number): () => void { register(hostId: number): () => void {
const count = this.counts.get(hostId) ?? 0; const count = this.counts.get(hostId) ?? 0;
this.counts.set(hostId, count + 1); this.counts.set(hostId, count + 1);
@@ -78,6 +78,7 @@ export interface HostStatusDeps {
target: StatusTarget, target: StatusTarget,
port: number, port: number,
) => Promise<boolean>; ) => Promise<boolean>;
hasActiveSession?: (hostId: number) => boolean;
globalInterval: () => number; globalInterval: () => number;
emit: (payload: HostStatusPayload) => void; emit: (payload: HostStatusPayload) => void;
} }
@@ -126,6 +127,7 @@ const defaultDeps: HostStatusDeps = {
); );
return [...new Set(entries.map((entry) => entry.hostId))]; return [...new Set(entries.map((entry) => entry.hostId))];
}, },
hasActiveSession: (hostId) => hostSessionStatus.hasActiveSession(hostId),
ping: (host, port) => tcpPing(host, port, 5000), ping: (host, port) => tcpPing(host, port, 5000),
pingThroughJumpHosts: async (target, port) => { pingThroughJumpHosts: async (target, port) => {
const { createJumpHostChain } = await import("../jump-host-chain.js"); const { createJumpHostChain } = await import("../jump-host-chain.js");
@@ -429,11 +431,15 @@ export class HostStatusService {
this.owners.set(target.id, target.userId); this.owners.set(target.id, target.userId);
let reachable = false; let reachable = false;
try { try {
const port = await this.portFor(target); if (this.deps.hasActiveSession?.(target.id)) {
reachable = reachable = true;
target.jumpHosts.length > 0 } else {
? await this.deps.pingThroughJumpHosts(target, port) const port = await this.portFor(target);
: await this.deps.ping(target.ip, port); reachable =
target.jumpHosts.length > 0
? await this.deps.pingThroughJumpHosts(target, port)
: await this.deps.ping(target.ip, port);
}
} catch { } catch {
reachable = false; reachable = false;
} }
+2 -2
View File
@@ -2,8 +2,8 @@ import net from "net";
import type { Client } from "ssh2"; import type { Client } from "ssh2";
/** /**
* Opens a TCP connection and closes it again. An SSH server gets a polite * Opens a TCP connection and closes it again. SSH probes exchange banners,
* banner back so it does not log a failed handshake. * but still close before authentication and may trigger aggressive Fail2Ban rules.
*/ */
export function tcpPing( export function tcpPing(
host: string, host: string,
@@ -13,10 +13,13 @@ describe("HostSessionStatus", () => {
expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenLastCalledWith(7, true); expect(listener).toHaveBeenLastCalledWith(7, true);
expect(status.hasActiveSession(7)).toBe(true);
closeFirst(); closeFirst();
expect(status.hasActiveSession(7)).toBe(true);
expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledTimes(1);
closeSecond(); closeSecond();
expect(status.hasActiveSession(7)).toBe(false);
expect(listener).toHaveBeenLastCalledWith(7, false); expect(listener).toHaveBeenLastCalledWith(7, false);
expect(listener).toHaveBeenCalledTimes(2); expect(listener).toHaveBeenCalledTimes(2);
}); });
@@ -52,6 +52,7 @@ function setup(
loadSharedHostIds: async (userId) => shared[userId] ?? [], loadSharedHostIds: async (userId) => shared[userId] ?? [],
ping, ping,
pingThroughJumpHosts, pingThroughJumpHosts,
hasActiveSession: (id) => hostSessionStatus.hasActiveSession(id),
globalInterval: () => 60, globalInterval: () => 60,
emit: (payload) => emitted.push(payload), emit: (payload) => emitted.push(payload),
}); });
@@ -74,6 +75,34 @@ afterEach(() => {
}); });
describe("HostStatusService", () => { 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 () => { it("starts a user's own hosts and reports them online", async () => {
const { service, emitted, ping } = setup([target(1), target(2)]); const { service, emitted, ping } = setup([target(1), target(2)]);
active = service; active = service;
+1 -1
View File
@@ -479,7 +479,7 @@
"sameHost": "This host (direct tunnel)", "sameHost": "This host (direct tunnel)",
"statusChecksLabel": "Status Checks", "statusChecksLabel": "Status Checks",
"enableStatusChecks": "Enable 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", "useGlobalInterval": "Use Global Interval",
"useGlobalIntervalDesc": "Override with the server-wide status check interval", "useGlobalIntervalDesc": "Override with the server-wide status check interval",
"checkIntervalS": "Check Interval (s)", "checkIntervalS": "Check Interval (s)",