109 lines
4.6 KiB
TypeScript
109 lines
4.6 KiB
TypeScript
|
|
import {
|
||
|
|
classifyDistroId,
|
||
|
|
detectVendorFromSshVersion,
|
||
|
|
normalizeDistroId,
|
||
|
|
} from "../../../domain/host";
|
||
|
|
import { logger } from "../../../lib/logger";
|
||
|
|
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Per-connection token for stale-timer detection. The renderer reuses the
|
||
|
|
* same sessionId across reconnects within a tab, so comparing sessionIds
|
||
|
|
* cannot distinguish "the current attempt" from "a previous attempt on
|
||
|
|
* the same slot". We assign each startSSH call a fresh token object and
|
||
|
|
* store it in this module-local map, keyed by sessionId. A timer that
|
||
|
|
* was scheduled under an older token will see a different value here and
|
||
|
|
* bail out. The map entry for a sessionId is overwritten on each new
|
||
|
|
* connect and stays around until the app exits — since there is only one
|
||
|
|
* entry per active session, the memory cost is negligible.
|
||
|
|
*/
|
||
|
|
const connectionTokensBySessionId = new Map<string, object>();
|
||
|
|
|
||
|
|
export const isConnectionTokenCurrent = (sessionId: string, token: object): boolean =>
|
||
|
|
connectionTokensBySessionId.get(sessionId) === token;
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
export const registerConnectionToken = (sessionId: string): object => {
|
||
|
|
const connectionToken = {};
|
||
|
|
connectionTokensBySessionId.set(sessionId, connectionToken);
|
||
|
|
return connectionToken;
|
||
|
|
};
|
||
|
|
|
||
|
|
export const clearConnectionToken = (sessionId: string): void => {
|
||
|
|
connectionTokensBySessionId.delete(sessionId);
|
||
|
|
};
|
||
|
|
|
||
|
|
export const runDistroDetection = async (
|
||
|
|
ctx: TerminalSessionStartersContext,
|
||
|
|
sessionId: string,
|
||
|
|
connectionToken: object,
|
||
|
|
) => {
|
||
|
|
// Stale-session guard: the renderer reuses ctx.sessionId across
|
||
|
|
// reconnects in the same tab, so comparing sessionIds is not enough.
|
||
|
|
// We compare against a per-connection token instead; if a newer
|
||
|
|
// connect attempt has run, it will have replaced the token in the
|
||
|
|
// module-level map and this check will fail. Repeated after every
|
||
|
|
// await because the session can change during an async call.
|
||
|
|
const isStillCurrent = () => isConnectionTokenCurrent(sessionId, connectionToken);
|
||
|
|
|
||
|
|
if (!isStillCurrent()) return;
|
||
|
|
const isKnownNetworkDevice =
|
||
|
|
ctx.host.deviceType === "network" ||
|
||
|
|
classifyDistroId(ctx.host.distro) === "network-device";
|
||
|
|
|
||
|
|
// Step 1: try to classify from the SSH server identification string
|
||
|
|
// captured at handshake time. This is free (no extra channel) and
|
||
|
|
// reliably identifies most network-device vendors (Cisco IOS, Huawei
|
||
|
|
// VRP, HPE Comware, MikroTik, Fortinet, etc.) so we can skip the
|
||
|
|
// POSIX-shell probe entirely for those hosts — which otherwise fails
|
||
|
|
// and, on devices like Cisco / Juniper with AAA logging, generates an
|
||
|
|
// extra session log entry per connect.
|
||
|
|
try {
|
||
|
|
if (ctx.terminalBackend.getSessionRemoteInfo && sessionId) {
|
||
|
|
const info = await ctx.terminalBackend.getSessionRemoteInfo(sessionId);
|
||
|
|
if (!isStillCurrent()) return;
|
||
|
|
if (!isKnownNetworkDevice && /^(?:SSH-(?:2\.0|1\.99)-)?OpenSSH_for_Windows(?:_|$)/i.test(info?.remoteSshVersion || '')) {
|
||
|
|
ctx.onOsDetected?.(ctx.host.id, 'windows');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const vendor = detectVendorFromSshVersion(info?.remoteSshVersion);
|
||
|
|
if (vendor) {
|
||
|
|
ctx.onOsDetected?.(ctx.host.id, vendor);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
logger.warn("SSH banner vendor detection failed", err);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!isStillCurrent()) return;
|
||
|
|
if (isKnownNetworkDevice) return;
|
||
|
|
|
||
|
|
// Step 2: unknown or generic OpenSSH/Dropbear — fall back to the
|
||
|
|
// /etc/os-release probe to pick a distro-specific icon. We deliberately
|
||
|
|
// use `getSessionDistroInfo` which runs the probe on the *existing*
|
||
|
|
// SSH connection's exec channel instead of spinning up a brand new
|
||
|
|
// SSH client the way `execCommand` would. That saves a full handshake
|
||
|
|
// round-trip on every connect, and on OpenSSH-fronted network devices
|
||
|
|
// that we couldn't identify from the banner (JUNOS, NX-OS, EOS) it
|
||
|
|
// avoids one extra AAA session log entry per connect.
|
||
|
|
try {
|
||
|
|
if (ctx.terminalBackend.getSessionDistroInfo && sessionId) {
|
||
|
|
const res = await ctx.terminalBackend.getSessionDistroInfo(sessionId);
|
||
|
|
if (!isStillCurrent()) return;
|
||
|
|
if (!res?.success) return;
|
||
|
|
const data = (res.stdout || "").trim();
|
||
|
|
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
|
||
|
|
const rawDistro = idMatch
|
||
|
|
? idMatch[1]
|
||
|
|
: (data.match(/^(Linux|Darwin|FreeBSD)\b/i)?.[1] || "").toLowerCase();
|
||
|
|
// An os-release ID confirms Linux even for distributions without a dedicated icon.
|
||
|
|
const distro = normalizeDistroId(rawDistro) || (idMatch ? 'linux' : '');
|
||
|
|
if (distro) ctx.onOsDetected?.(ctx.host.id, distro);
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
logger.warn("OS probe failed", err);
|
||
|
|
}
|
||
|
|
};
|