Files
Netcatty/application/state/useTerminalBackend.ts
T
陈大猫 e8b9122270 feat: auto-detect network devices from SSH banner and skip stats polling (#680)
* 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>
2026-04-10 14:02:24 +08:00

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,
};
};