diff --git a/src/backend/ssh/host-metrics-helpers.test.ts b/src/backend/ssh/host-metrics-helpers.test.ts index 70714d9f..08d766fa 100644 --- a/src/backend/ssh/host-metrics-helpers.test.ts +++ b/src/backend/ssh/host-metrics-helpers.test.ts @@ -1,8 +1,10 @@ -import { describe, it, expect } from "vitest"; +import type { Client } from "ssh2"; +import { describe, it, expect, vi } from "vitest"; import { supportsMetrics, isTcpPingEnabled, createConnectionLog, + tcpPingThroughJumpHost, } from "./host-metrics-helpers.js"; describe("supportsMetrics", () => { @@ -66,3 +68,57 @@ describe("createConnectionLog", () => { expect("timestamp" in entry).toBe(false); }); }); + +describe("tcpPingThroughJumpHost", () => { + it("reports the final destination online when forwarding succeeds", async () => { + const stream = { destroy: vi.fn() }; + const jumpClient = { + end: vi.fn(), + forwardOut: vi.fn((_src, _srcPort, host, port, callback) => { + expect(host).toBe("private.example"); + expect(port).toBe(22); + callback(undefined, stream); + }), + } as unknown as Pick; + + await expect( + tcpPingThroughJumpHost(jumpClient, "private.example", 22), + ).resolves.toBe(true); + expect(stream.destroy).toHaveBeenCalledOnce(); + expect(jumpClient.end).toHaveBeenCalledOnce(); + }); + + it("reports the final destination offline when forwarding fails", async () => { + const jumpClient = { + end: vi.fn(), + forwardOut: vi.fn((_src, _srcPort, _host, _port, callback) => { + callback(new Error("Connection refused")); + }), + } as unknown as Pick; + + await expect( + tcpPingThroughJumpHost(jumpClient, "private.example", 22), + ).resolves.toBe(false); + expect(jumpClient.end).toHaveBeenCalledOnce(); + }); + + it("reports the final destination offline when forwarding times out", async () => { + vi.useFakeTimers(); + const jumpClient = { + end: vi.fn(), + forwardOut: vi.fn(), + } as unknown as Pick; + + const result = tcpPingThroughJumpHost( + jumpClient, + "private.example", + 22, + 5000, + ); + await vi.advanceTimersByTimeAsync(5000); + + await expect(result).resolves.toBe(false); + expect(jumpClient.end).toHaveBeenCalledOnce(); + vi.useRealTimers(); + }); +}); diff --git a/src/backend/ssh/host-metrics-helpers.ts b/src/backend/ssh/host-metrics-helpers.ts index e9977413..3b3260e9 100644 --- a/src/backend/ssh/host-metrics-helpers.ts +++ b/src/backend/ssh/host-metrics-helpers.ts @@ -1,4 +1,5 @@ import type { LogEntry, ConnectionStage } from "../../types/connection-log.js"; +import type { Client } from "ssh2"; export type StatsCapableHost = { connectionType?: string; @@ -21,6 +22,32 @@ export function isTcpPingEnabled(statsConfig: TcpPingStatsConfig): boolean { return statsConfig.statusCheckEnabled && !statsConfig.disableTcpPing; } +export function tcpPingThroughJumpHost( + jumpClient: Pick, + host: string, + port: number, + timeoutMs = 5000, +): Promise { + return new Promise((resolve) => { + let settled = false; + + const finish = (result: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + jumpClient.end(); + resolve(result); + }; + + const timeout = setTimeout(() => finish(false), timeoutMs); + + jumpClient.forwardOut("127.0.0.1", 0, host, port, (error, stream) => { + stream?.destroy(); + finish(!error && !!stream); + }); + }); +} + export function createConnectionLog( type: "info" | "success" | "warning" | "error", stage: ConnectionStage, diff --git a/src/backend/ssh/host-metrics.ts b/src/backend/ssh/host-metrics.ts index 532c9307..07dd9298 100644 --- a/src/backend/ssh/host-metrics.ts +++ b/src/backend/ssh/host-metrics.ts @@ -49,6 +49,7 @@ import { createConnectionLog, isTcpPingEnabled, supportsMetrics, + tcpPingThroughJumpHost, } from "./host-metrics-helpers.js"; import { applyAgentAuth } from "./terminal-auth-helpers.js"; import { @@ -361,24 +362,45 @@ class PollingManager { } try { - let pingHost = refreshedHost.ip; const ct = refreshedHost.connectionType || "ssh"; let pingPort: number; if (ct === "rdp") pingPort = refreshedHost.rdpPort ?? 3389; else if (ct === "vnc") pingPort = refreshedHost.vncPort ?? 5900; else if (ct === "telnet") pingPort = refreshedHost.telnetPort ?? 23; else pingPort = refreshedHost.port; + + let isOnline: boolean; if (refreshedHost.jumpHosts && refreshedHost.jumpHosts.length > 0) { - const firstJump = await fetchHostById( - refreshedHost.jumpHosts[0].hostId, + const proxyConfig: SOCKS5Config | null = + refreshedHost.useSocks5 && + (refreshedHost.socks5Host || + (refreshedHost.socks5ProxyChain && + refreshedHost.socks5ProxyChain.length > 0)) + ? { + useSocks5: true, + socks5Host: refreshedHost.socks5Host, + socks5Port: refreshedHost.socks5Port, + socks5Username: refreshedHost.socks5Username, + socks5Password: refreshedHost.socks5Password, + socks5ProxyChain: refreshedHost.socks5ProxyChain, + } + : null; + const jumpClient = await createJumpHostChain( + refreshedHost.jumpHosts, userId, + proxyConfig, ); - if (firstJump) { - pingHost = firstJump.ip; - pingPort = firstJump.port; - } + isOnline = jumpClient + ? await tcpPingThroughJumpHost( + jumpClient, + refreshedHost.ip, + pingPort, + 5000, + ) + : false; + } else { + isOnline = await tcpPing(refreshedHost.ip, pingPort, 5000); } - const isOnline = await tcpPing(pingHost, pingPort, 5000); const statusEntry: StatusEntry = { status: isOnline ? "online" : "offline", lastChecked: new Date().toISOString(),