mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
e8b9122270
* feat: auto-detect network devices from SSH banner and skip stats polling (#674) Fixes rapid AAA session churn reported on Cisco/HPE/similar network devices running Netcatty. The root cause was two separate polls that both open fresh exec channels (each counted as its own AAA session on many network devices): - runDistroDetection() opens a brand new SSH connection every time a host connects to run `cat /etc/os-release || uname -a` - useServerStats polls `conn.exec(statsCommand)` every 5 seconds Both commands fail on non-POSIX CLIs, but the channels still hit AAA. This change avoids both by reading the SSH server identification string that ssh2 already captures during the handshake (`conn._remoteVer`). No extra network round-trips, zero additional AAA entries. ## Changes **sshBridge.cjs** - Store `conn._remoteVer` on the session object at connect time as `session.remoteSshVersion` - New IPC handler `netcatty:ssh:remoteInfo` (`getSessionRemoteInfo`) returning the captured SSH server software string **preload.cjs / global.d.ts / useTerminalBackend.ts** - Thread `getSessionRemoteInfo(sessionId)` through to the renderer **domain/host.ts** - `NETWORK_DEVICE_OPTIONS` constant listing the vendor IDs we can recognize (cisco, juniper, huawei, hpe, mikrotik, fortinet, paloalto, zyxel) - `detectVendorFromSshVersion()` — pure function that parses an SSH server software string and returns a vendor ID or ''. Pattern set is sourced from Nmap nmap-service-probes (authoritative), the ssh-audit software.py reference, and vendor docs; see code comments for the exact matches used. - `classifyDistroId()` returns `linux-like | network-device | other` so features that require a POSIX shell can gate on the result. **createTerminalSessionStarters.ts (runDistroDetection)** - Before running the /etc/os-release probe, call `getSessionRemoteInfo` on the already-connected session and feed the banner into `detectVendorFromSshVersion`. If the vendor maps to a known network device, emit the vendor ID via the existing `onOsDetected` callback and skip the shell probe entirely. For unknown or generic OpenSSH/Dropbear banners the existing behavior is preserved. **Terminal.tsx** - `isSupportedOs` now derives from `classifyDistroId(effectiveDistro)` combined with `host.deviceType !== 'network'`, so neither explicit network-device hosts nor banner-detected ones trigger the stats polling loop. **useServerStats.ts** - Add a consecutive-failure counter. After 3 consecutive failed polls, stop the interval for this session (reset on disconnect / sessionId change / settings toggle). This is the fallback for hosts the banner classifier cannot identify (Juniper JUNOS, Cisco NX-OS, Arista EOS — all present as plain `OpenSSH_*` but do not support the POSIX stats pipeline). **DistroAvatar.tsx / HostDetailsPanel.tsx** - Add 8 network-device vendor icons (Cisco, Juniper, Huawei, HPE, MikroTik, Fortinet, Palo Alto, ZyXEL) alongside the existing Linux distro icons, with brand colors. Icons sourced from Simple Icons (CC0) where available; HPE and ZyXEL use simple abbreviation placeholders. - Network device vendors are added to the manual distro override dropdown so users can pin an icon even if their device has an exotic banner we don't auto-detect. **i18n** - English + Chinese labels for the new vendor options in the Host Details distro selector. Closes #674 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: gate network-device detection on raw host.distro, not manual icon override Per Codex review on PR #680: the stats-polling gate was passing `host` through getEffectiveHostDistro() before classifying, which honors the manual distro override (`distroMode: 'manual'` + `manualDistro`). That meant a user who previously pinned an "ubuntu" icon on a host that later gets banner-detected as Cisco would still be classified as linux-like and keep generating the AAA session flood #674 is meant to eliminate. Separate display from gating: - Display (DistroAvatar, host cards): keeps using getEffectiveHostDistro so users can cosmetically override the icon. - Gating (useServerStats via Terminal.tsx isSupportedOs): reads host.distro directly — the value populated by banner detection — alongside the explicit host.deviceType flag. Manual icon choice can no longer re-enable polling on a detected network device. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: guard distro detection against stale session timers Per Codex review on PR #680: runDistroDetection is scheduled on a 600ms setTimeout after connection and also makes async calls of its own. A quick disconnect + reconnect on the same session slot could fire the old timer against the new session, reading host B's SSH banner via getSessionRemoteInfo and writing host B's vendor onto host A's distro field — wrong icon and wrong stats-polling state. Follow the same pattern already used for the startup-command timer in this file (scheduledSessionId captured at schedule time, checked inside the timer). Capture `id` at schedule time, bail out if ctx.sessionRef.current no longer matches, and re-check after every async await inside runDistroDetection so that a reconnect during the banner fetch or the os-release probe also bails cleanly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address local codex review on PR #680 Addresses three issues found in a local Codex review pass after the remote reviewer gate was flaky: ## P0 — session tokens instead of sessionId for stale-timer guard The previous guard captured `id` returned from startSSHSession and compared against `ctx.sessionRef.current` inside the setTimeout and the async runDistroDetection. But the renderer passes `sessionId: ctx.sessionId` into startSSHSession (see createTerminalSessionStarters.ts:543), meaning a tab reuses the SAME sessionId across disconnect+reconnect. The comparison `T1 === T1` always passed, so the guard was a no-op. Replaced with a module-level Map<sessionId, object> that stores the live "connection token" for each sessionId slot. Each call to startSSH mints a fresh `{}` token and overwrites the entry. Timers and async continuations compare their captured token against the current map value by reference — a reconnect replaces the map entry with a new token, so stale callbacks bail cleanly. ## P1 — run os-release probe on the existing SSH connection The fallback /etc/os-release probe used `execCommand` which creates a brand-new SSHClient() on every call. On network devices that present as plain `OpenSSH_*` and fall through to this step (JUNOS, NX-OS, EOS) it added one extra full-auth AAA session log entry per connect, in addition to the failing stats polls. Added `getSessionDistroInfo(sessionId)` as a new IPC handler that runs the same probe via `session.conn.exec()` — an exec channel on the already-open connection, no new handshake. Plumbed through preload.cjs, global.d.ts, and useTerminalBackend.ts. runDistroDetection uses this instead of execCommand in the fallback path, also removing the unused auth-credentials argument (we are no longer opening a new connection, so no credentials are needed). ## P2.1 — don't re-arm timers after giving up After the consecutive-failure counter trips, useServerStats cleared the interval but a subsequent effect rerun (visibility change, settings tweak, etc.) would schedule a fresh `setTimeout` and `setInterval` that would just call the early-return path forever. The scheduling block now checks `givenUpRef.current` before arming either timer. The flag is still cleared on the normal disconnect / sessionId-change reset path so a reconnect gets a fresh attempt. ## P2.2 — drop the ambiguous IPSSH-* → cisco mapping Nmap's `match ssh m|^SSH-([\d.]+)-IPSSH-` line is labelled as `Cisco/3com IPSSHd` — it cannot identify a specific vendor from the banner alone. Mapping it to `cisco` would risk showing the wrong vendor icon on a 3Com device. Removed the rule entirely and documented why with a code comment; users with such devices can still use the Host Details manual distro override. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address remaining gaps from local codex follow-up review P0 gap — delete connection token on session exit. Previously the map entry lingered after disconnect, so a very late-firing timer could still pass the isConnectionTokenCurrent check even though the session no longer existed. Functionally harmless (the IPC calls would fail) but semantically wrong. Now connectionTokensBySessionId.delete() is called in the onSessionExit handler. P1 new — exec channel leak on timeout in getSessionDistroInfo. The timeout branch resolved the promise but didn't close the stream, so a hanging remote command would leave the exec channel open until the SSH connection itself dropped. Added a settled guard (resolve-once) and stream.close() on timeout. P2.1 gap — givenUpRef not reset on sessionId change. The failure counter reset only happened in the !isConnected branch of the main effect, so a sessionId swap while still connected (rare, but possible if the tab reconnects without toggling connected state) would permanently suppress polling. Added a small dedicated effect that resets both counters when sessionId changes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
182 lines
6.4 KiB
TypeScript
182 lines
6.4 KiB
TypeScript
import { useCallback } from "react";
|
|
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
|
|
|
export const useTerminalBackend = () => {
|
|
const telnetAvailable = useCallback(() => {
|
|
const bridge = netcattyBridge.get();
|
|
return !!bridge?.startTelnetSession;
|
|
}, []);
|
|
|
|
const moshAvailable = useCallback(() => {
|
|
const bridge = netcattyBridge.get();
|
|
return !!bridge?.startMoshSession;
|
|
}, []);
|
|
|
|
const localAvailable = useCallback(() => {
|
|
const bridge = netcattyBridge.get();
|
|
return !!bridge?.startLocalSession;
|
|
}, []);
|
|
|
|
const serialAvailable = useCallback(() => {
|
|
const bridge = netcattyBridge.get();
|
|
return !!bridge?.startSerialSession;
|
|
}, []);
|
|
|
|
const execAvailable = useCallback(() => {
|
|
const bridge = netcattyBridge.get();
|
|
return !!bridge?.execCommand;
|
|
}, []);
|
|
|
|
const startSSHSession = useCallback(async (options: NetcattySSHOptions) => {
|
|
const bridge = netcattyBridge.get();
|
|
if (!bridge?.startSSHSession) throw new Error("startSSHSession unavailable");
|
|
return bridge.startSSHSession(options);
|
|
}, []);
|
|
|
|
const startTelnetSession = useCallback(async (options: Parameters<NonNullable<NetcattyBridge["startTelnetSession"]>>[0]) => {
|
|
const bridge = netcattyBridge.get();
|
|
if (!bridge?.startTelnetSession) throw new Error("startTelnetSession unavailable");
|
|
return bridge.startTelnetSession(options);
|
|
}, []);
|
|
|
|
const startMoshSession = useCallback(async (options: Parameters<NonNullable<NetcattyBridge["startMoshSession"]>>[0]) => {
|
|
const bridge = netcattyBridge.get();
|
|
if (!bridge?.startMoshSession) throw new Error("startMoshSession unavailable");
|
|
return bridge.startMoshSession(options);
|
|
}, []);
|
|
|
|
const startLocalSession = useCallback(async (options: Parameters<NonNullable<NetcattyBridge["startLocalSession"]>>[0]) => {
|
|
const bridge = netcattyBridge.get();
|
|
if (!bridge?.startLocalSession) throw new Error("startLocalSession unavailable");
|
|
return bridge.startLocalSession(options);
|
|
}, []);
|
|
|
|
const startSerialSession = useCallback(async (options: Parameters<NonNullable<NetcattyBridge["startSerialSession"]>>[0]) => {
|
|
const bridge = netcattyBridge.get();
|
|
if (!bridge?.startSerialSession) throw new Error("startSerialSession unavailable");
|
|
return bridge.startSerialSession(options);
|
|
}, []);
|
|
|
|
const execCommand = useCallback(async (options: Parameters<NetcattyBridge["execCommand"]>[0]) => {
|
|
const bridge = netcattyBridge.get();
|
|
if (!bridge?.execCommand) throw new Error("execCommand unavailable");
|
|
return bridge.execCommand(options);
|
|
}, []);
|
|
|
|
const writeToSession = useCallback((sessionId: string, data: string) => {
|
|
const bridge = netcattyBridge.get();
|
|
bridge?.writeToSession?.(sessionId, data);
|
|
}, []);
|
|
|
|
const resizeSession = useCallback((sessionId: string, cols: number, rows: number) => {
|
|
const bridge = netcattyBridge.get();
|
|
bridge?.resizeSession?.(sessionId, cols, rows);
|
|
}, []);
|
|
|
|
const closeSession = useCallback((sessionId: string) => {
|
|
const bridge = netcattyBridge.get();
|
|
bridge?.closeSession?.(sessionId);
|
|
}, []);
|
|
|
|
const setSessionEncoding = useCallback(async (sessionId: string, encoding: string) => {
|
|
const bridge = netcattyBridge.get();
|
|
if (!bridge?.setSessionEncoding) return { ok: false, encoding };
|
|
return bridge.setSessionEncoding(sessionId, encoding);
|
|
}, []);
|
|
|
|
const onSessionData = useCallback((sessionId: string, cb: (data: string) => void) => {
|
|
const bridge = netcattyBridge.get();
|
|
if (!bridge?.onSessionData) throw new Error("onSessionData unavailable");
|
|
return bridge.onSessionData(sessionId, cb);
|
|
}, []);
|
|
|
|
const onSessionExit = useCallback((sessionId: string, cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void) => {
|
|
const bridge = netcattyBridge.get();
|
|
if (!bridge?.onSessionExit) throw new Error("onSessionExit unavailable");
|
|
return bridge.onSessionExit(sessionId, cb);
|
|
}, []);
|
|
|
|
const onChainProgress = useCallback((cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void) => {
|
|
const bridge = netcattyBridge.get();
|
|
return bridge?.onChainProgress?.(cb);
|
|
}, []);
|
|
|
|
const openExternal = useCallback(async (url: string) => {
|
|
const bridge = netcattyBridge.get();
|
|
await bridge?.openExternal?.(url);
|
|
}, []);
|
|
|
|
const openExternalAvailable = useCallback(() => {
|
|
const bridge = netcattyBridge.get();
|
|
return !!bridge?.openExternal;
|
|
}, []);
|
|
|
|
const backendAvailable = useCallback(() => {
|
|
const bridge = netcattyBridge.get();
|
|
return !!bridge?.startSSHSession;
|
|
}, []);
|
|
|
|
const listSerialPorts = useCallback(async () => {
|
|
const bridge = netcattyBridge.get();
|
|
if (!bridge?.listSerialPorts) return [];
|
|
return bridge.listSerialPorts();
|
|
}, []);
|
|
|
|
const getSessionPwd = useCallback(async (sessionId: string) => {
|
|
const bridge = netcattyBridge.get();
|
|
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
|
|
return bridge.getSessionPwd(sessionId);
|
|
}, []);
|
|
|
|
const getSessionRemoteInfo = useCallback(async (sessionId: string) => {
|
|
const bridge = netcattyBridge.get();
|
|
if (!bridge?.getSessionRemoteInfo) {
|
|
return { success: false, error: 'getSessionRemoteInfo unavailable' };
|
|
}
|
|
return bridge.getSessionRemoteInfo(sessionId);
|
|
}, []);
|
|
|
|
const getSessionDistroInfo = useCallback(async (sessionId: string) => {
|
|
const bridge = netcattyBridge.get();
|
|
if (!bridge?.getSessionDistroInfo) {
|
|
return { success: false, error: 'getSessionDistroInfo unavailable' };
|
|
}
|
|
return bridge.getSessionDistroInfo(sessionId);
|
|
}, []);
|
|
|
|
const getServerStats = useCallback(async (sessionId: string) => {
|
|
const bridge = netcattyBridge.get();
|
|
if (!bridge?.getServerStats) return { success: false, error: 'getServerStats unavailable' };
|
|
return bridge.getServerStats(sessionId);
|
|
}, []);
|
|
|
|
return {
|
|
backendAvailable,
|
|
telnetAvailable,
|
|
moshAvailable,
|
|
localAvailable,
|
|
serialAvailable,
|
|
execAvailable,
|
|
openExternalAvailable,
|
|
startSSHSession,
|
|
startTelnetSession,
|
|
startMoshSession,
|
|
startLocalSession,
|
|
startSerialSession,
|
|
listSerialPorts,
|
|
execCommand,
|
|
getSessionPwd,
|
|
getSessionRemoteInfo,
|
|
getSessionDistroInfo,
|
|
getServerStats,
|
|
writeToSession,
|
|
resizeSession,
|
|
closeSession,
|
|
setSessionEncoding,
|
|
onSessionData,
|
|
onSessionExit,
|
|
onChainProgress,
|
|
openExternal,
|
|
};
|
|
};
|