340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
import { Host, TerminalSettings } from './models';
|
|
import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts';
|
|
|
|
export type HostLabelRenameResult =
|
|
| { ok: true; changed: true; hosts: Host[] }
|
|
| { ok: true; changed: false; reason: 'unchanged' | 'missing'; hosts: Host[] }
|
|
| { ok: false; changed: false; reason: 'required'; hosts: Host[] };
|
|
|
|
export function applyHostLabelRename(
|
|
hosts: Host[],
|
|
hostId: string,
|
|
rawLabel: string,
|
|
): HostLabelRenameResult {
|
|
const nextLabel = rawLabel.trim();
|
|
if (!nextLabel) {
|
|
return { ok: false, changed: false, reason: 'required', hosts };
|
|
}
|
|
|
|
let found = false;
|
|
let changed = false;
|
|
const nextHosts = hosts.map((host) => {
|
|
if (host.id !== hostId) return host;
|
|
found = true;
|
|
if (host.label === nextLabel) return host;
|
|
changed = true;
|
|
return { ...host, label: nextLabel };
|
|
});
|
|
|
|
if (!found) return { ok: true, changed: false, reason: 'missing', hosts };
|
|
if (!changed) return { ok: true, changed: false, reason: 'unchanged', hosts };
|
|
return { ok: true, changed: true, hosts: nextHosts };
|
|
}
|
|
|
|
export const LINUX_DISTRO_OPTIONS = [
|
|
'linux',
|
|
'ubuntu',
|
|
'debian',
|
|
'centos',
|
|
'rocky',
|
|
'fedora',
|
|
'arch',
|
|
'alpine',
|
|
'amazon',
|
|
'opensuse',
|
|
'redhat',
|
|
'almalinux',
|
|
'oracle',
|
|
'kali',
|
|
'alinux',
|
|
'openeuler',
|
|
] as const;
|
|
|
|
/**
|
|
* Known network-device vendor IDs that Netcatty can detect from the SSH
|
|
* server identification string. When a host is classified as one of these,
|
|
* features that assume a POSIX shell (e.g. the periodic server stats poll)
|
|
* are disabled, because the stats command would either be rejected outright
|
|
* or generate one AAA session log per poll on the remote device.
|
|
*/
|
|
export const NETWORK_DEVICE_OPTIONS = [
|
|
'cisco',
|
|
'juniper',
|
|
'huawei',
|
|
'hpe',
|
|
'mikrotik',
|
|
'fortinet',
|
|
'paloalto',
|
|
'zyxel',
|
|
'ruijie',
|
|
] as const;
|
|
|
|
export type NetworkDeviceVendor = typeof NETWORK_DEVICE_OPTIONS[number];
|
|
|
|
export const normalizeDistroId = (value?: string) => {
|
|
const v = (value || '').toLowerCase().trim();
|
|
if (!v) return '';
|
|
if (v.includes('ubuntu')) return 'ubuntu';
|
|
if (v.includes('debian')) return 'debian';
|
|
if (v.includes('centos')) return 'centos';
|
|
if (v.includes('rocky')) return 'rocky';
|
|
if (v.includes('fedora')) return 'fedora';
|
|
if (v.includes('arch') || v.includes('manjaro')) return 'arch';
|
|
if (v.includes('alpine')) return 'alpine';
|
|
if (v.includes('amzn') || v.includes('amazon') || v.includes('aws')) return 'amazon';
|
|
if (v.includes('opensuse') || v.includes('suse') || v.includes('sles')) return 'opensuse';
|
|
if (v.includes('red hat') || v.includes('redhat') || v.includes('rhel')) return 'redhat';
|
|
if (v.includes('almalinux')) return 'almalinux';
|
|
if (v.includes('oracle')) return 'oracle';
|
|
if (v.includes('kali')) return 'kali';
|
|
if (v.includes('openeuler') || v.includes('open euler')) return 'openeuler';
|
|
// Alibaba Cloud Linux: os-release ID is `alinux` (older branding: Aliyun
|
|
// Linux / `aliyun`). Must come before the generic `linux` fallback because
|
|
// 'alinux'.includes('linux') is true and would otherwise resolve to 'linux'.
|
|
if (v.includes('alinux') || v.includes('aliyun') || v.includes('alibaba cloud')) {
|
|
return 'alinux';
|
|
}
|
|
// Network device vendor IDs may arrive here after detection — preserve them.
|
|
if ((NETWORK_DEVICE_OPTIONS as readonly string[]).includes(v)) return v;
|
|
if (v === 'linux' || v.includes('linux')) return 'linux';
|
|
return '';
|
|
};
|
|
|
|
/**
|
|
* Parse the SSH server identification string (the `software` portion of
|
|
* `SSH-<protocol>-<software>\r\n`, exposed by ssh2 as `conn._remoteVer`)
|
|
* and return a normalized network-device vendor ID, or '' if the ident
|
|
* does not match a known vendor.
|
|
*
|
|
* Matching patterns are sourced from the Nmap nmap-service-probes
|
|
* database (match lines beginning with `match ssh m|^SSH-`), cross-
|
|
* referenced with the ssh-audit project's software.py and vendor docs.
|
|
* Only rules whose pattern can be reproduced from those sources are
|
|
* included here.
|
|
*
|
|
* Empty string means "use the fallback linux/macos detection path" —
|
|
* that is what happens for OpenSSH, Dropbear, JUNOS, Cisco NX-OS, and
|
|
* Arista EOS, all of which either are POSIX systems or present as
|
|
* plain `OpenSSH_*` with no distinct vendor marker.
|
|
*/
|
|
export const detectVendorFromSshVersion = (softwareVersion?: string): '' | NetworkDeviceVendor => {
|
|
const s = (softwareVersion || '').trim().replace(/^SSH-(?:2\.0|1\.99)-/i, '');
|
|
if (!s) return '';
|
|
|
|
// Cisco family — IOS, IOS XA, Wireless LAN Controller
|
|
if (/^Cisco[-_]/i.test(s)) return 'cisco';
|
|
if (/^CiscoIOS_/i.test(s)) return 'cisco';
|
|
if (/^CISCO_WLC\b/.test(s)) return 'cisco';
|
|
// Note: `IPSSH-*` is used by both Cisco and 3Com devices (per Nmap
|
|
// `match ssh m|^SSH-([\d.]+)-IPSSH-([\d.]+)\|`), so we cannot map it
|
|
// to a specific vendor icon from the banner alone. Users who want a
|
|
// custom icon for such devices can set one via the Host Details
|
|
// manual distro override. The stats-polling gate is still handled
|
|
// correctly via `host.deviceType === 'network'`.
|
|
|
|
// Juniper NetScreen firewall (JUNOS itself uses OpenSSH and is caught
|
|
// by the fallback failure-counter path in useServerStats).
|
|
if (/^NetScreen\b/.test(s)) return 'juniper';
|
|
|
|
// Huawei VRP and related products
|
|
if (s === '-') return 'huawei';
|
|
if (/^HUAWEI[-_]/i.test(s)) return 'huawei';
|
|
if (/^VRP-/i.test(s)) return 'huawei';
|
|
|
|
// HPE / H3C — Comware switches, Integrated Lights-Out (iLO), legacy 3Com
|
|
if (/^Comware-/i.test(s)) return 'hpe';
|
|
if (/^3Com\s*OS/i.test(s)) return 'hpe';
|
|
if (/^mpSSH_/i.test(s)) return 'hpe';
|
|
|
|
// MikroTik RouterOS
|
|
if (/^ROSSSH\b/.test(s)) return 'mikrotik';
|
|
|
|
// Fortinet FortiOS / FortiGate
|
|
if (/^FortiSSH_/i.test(s)) return 'fortinet';
|
|
|
|
// Palo Alto Networks PAN-OS
|
|
if (/^PaloAltoNetworks[_-]/i.test(s)) return 'paloalto';
|
|
|
|
// ZyXEL ZyWALL
|
|
if (/^Zyxel\s*SSH/i.test(s)) return 'zyxel';
|
|
|
|
// Ruijie RGOS
|
|
if (/^RGOS_SSH\b/i.test(s)) return 'ruijie';
|
|
|
|
return '';
|
|
};
|
|
|
|
/**
|
|
* Classify a distro/vendor ID into a high-level device class. Features that
|
|
* assume a POSIX shell (periodic stats polling, /etc/os-release probing, etc.)
|
|
* should only run when this returns `linux-like`.
|
|
*/
|
|
export type DeviceClass = 'linux-like' | 'network-device' | 'other';
|
|
|
|
export const classifyDistroId = (distroId?: string): DeviceClass => {
|
|
const v = (distroId || '').toLowerCase().trim();
|
|
if (!v) return 'other';
|
|
if ((NETWORK_DEVICE_OPTIONS as readonly string[]).includes(v)) return 'network-device';
|
|
if ((LINUX_DISTRO_OPTIONS as readonly string[]).includes(v)) return 'linux-like';
|
|
return 'other';
|
|
};
|
|
|
|
/**
|
|
* Decide whether it is safe to run the post-connect `pwd` probe that
|
|
* discovers the session's working directory. The probe opens an extra exec
|
|
* channel running a POSIX-shell script; strict network-device CLIs such as
|
|
* Huawei VRP respond by closing the whole SSH session (#1043), so it must be
|
|
* skipped for them.
|
|
*
|
|
* `isNetworkDevice` covers hosts we already classified (a reconnect, or an
|
|
* explicit `deviceType: 'network'`). On a brand-new host that field is not
|
|
* populated yet, so we also inspect the SSH server identification banner —
|
|
* captured for free at handshake — which identifies most vendors directly.
|
|
*/
|
|
export const shouldProbeSessionCwd = (opts: {
|
|
isNetworkDevice: boolean;
|
|
remoteSshVersion?: string;
|
|
}): boolean =>
|
|
!opts.isNetworkDevice && !detectVendorFromSshVersion(opts.remoteSshVersion);
|
|
|
|
export const getEffectiveHostDistro = (
|
|
host?: Pick<Host, 'distro' | 'manualDistro' | 'distroMode'> | null,
|
|
) => {
|
|
if (!host) return '';
|
|
const detected = normalizeDistroId(host.distro);
|
|
const manual = normalizeDistroId(host.manualDistro);
|
|
if (host.distroMode === 'manual') return manual || detected;
|
|
if (host.distroMode === 'auto') return detected;
|
|
return detected;
|
|
};
|
|
|
|
/** Format hostname:port for display, wrapping IPv6 addresses in brackets. */
|
|
export const formatHostPort = (hostname: string, port?: number | null): string => {
|
|
if (port == null) return hostname;
|
|
const isIPv6 = hostname.includes(':') && !hostname.startsWith('[');
|
|
const display = isIPv6 ? `[${hostname}]` : hostname;
|
|
return `${display}:${port}`;
|
|
};
|
|
|
|
export const resolveTelnetUsername = (
|
|
host: Pick<Host, 'telnetUsername' | 'username'>,
|
|
): string | undefined =>
|
|
host.telnetUsername !== undefined
|
|
? host.telnetUsername.trim()
|
|
: host.username?.trim();
|
|
|
|
export const resolveTelnetPassword = (
|
|
host: Pick<Host, 'telnetPassword' | 'password'>,
|
|
): string | undefined =>
|
|
host.telnetPassword !== undefined
|
|
? host.telnetPassword
|
|
: host.password;
|
|
|
|
export const resolveTelnetPort = (
|
|
host: Pick<Host, 'protocol' | 'telnetPort' | 'port'>,
|
|
): number => {
|
|
if (host.telnetPort !== undefined && host.telnetPort !== null) return host.telnetPort;
|
|
if (host.protocol === 'telnet' && host.port !== undefined && host.port !== null) {
|
|
return host.port;
|
|
}
|
|
return 23;
|
|
};
|
|
|
|
export const normalizePrimaryTelnetState = (host: Host): Host =>
|
|
host.protocol === 'telnet' && !host.telnetEnabled
|
|
? { ...host, telnetEnabled: true }
|
|
: host;
|
|
|
|
export const migrateHostsFromLegacyLineTimestamps = (
|
|
hosts: Host[],
|
|
legacyEnabled: boolean,
|
|
): Host[] => {
|
|
if (!legacyEnabled) return hosts;
|
|
let changed = false;
|
|
const migrated = hosts.map((host) => {
|
|
if (host.showLineTimestamps !== undefined) return host;
|
|
changed = true;
|
|
return { ...host, showLineTimestamps: true };
|
|
});
|
|
return changed ? migrated : hosts;
|
|
};
|
|
|
|
export const preserveConcurrentHostLineTimestampUpdate = ({
|
|
draft,
|
|
openedHost,
|
|
latestHost,
|
|
}: {
|
|
draft: Host;
|
|
openedHost?: Host | null;
|
|
latestHost?: Host | null;
|
|
}): Host => {
|
|
if (!openedHost || !latestHost) return draft;
|
|
if (draft.id !== openedHost.id || draft.id !== latestHost.id) return draft;
|
|
if (draft.showLineTimestamps !== openedHost.showLineTimestamps) return draft;
|
|
if (latestHost.showLineTimestamps === openedHost.showLineTimestamps) return draft;
|
|
return { ...draft, showLineTimestamps: latestHost.showLineTimestamps };
|
|
};
|
|
|
|
export const upsertHostById = (hosts: Host[], host: Host): Host[] => {
|
|
const hostExists = hosts.some((entry) => entry.id === host.id);
|
|
return hostExists
|
|
? hosts.map((entry) => (entry.id === host.id ? host : entry))
|
|
: [...hosts, host];
|
|
};
|
|
|
|
export interface ResolvedKeepalive {
|
|
interval: number; // Seconds; 0 = disabled
|
|
countMax: number; // Unanswered keepalives before declaring dead
|
|
source: 'host' | 'global';
|
|
}
|
|
|
|
/**
|
|
* Decide which SSH keepalive values to apply to a connection. A host can opt
|
|
* into its own values via `keepaliveOverride === true` — useful when a
|
|
* specific device (older router / switch / NOKIA / ALCATEL SSH stack) doesn't
|
|
* reply to keepalive@openssh.com and the global aggressive setting would
|
|
* cause the session to be declared dead after a handful of unanswered probes.
|
|
* When the override is off (the default), the host inherits the global
|
|
* TerminalSettings values which are tuned for cloud / NAT'd hosts.
|
|
*
|
|
* Each field falls back independently: a host can override only the interval
|
|
* while still inheriting the global countMax, and vice versa.
|
|
*/
|
|
export const resolveHostKeepalive = (
|
|
host: Pick<Host, 'keepaliveOverride' | 'keepaliveInterval' | 'keepaliveCountMax'>,
|
|
globalSettings: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>,
|
|
): ResolvedKeepalive => {
|
|
const globalInterval = globalSettings.keepaliveInterval;
|
|
const globalCountMax = globalSettings.keepaliveCountMax;
|
|
if (host.keepaliveOverride !== true) {
|
|
return { interval: globalInterval, countMax: globalCountMax, source: 'global' };
|
|
}
|
|
return {
|
|
interval: host.keepaliveInterval ?? globalInterval,
|
|
countMax: host.keepaliveCountMax ?? globalCountMax,
|
|
source: 'host',
|
|
};
|
|
};
|
|
|
|
export const sanitizeHost = (host: Host): Host => {
|
|
const cleanHostname = (host.hostname || '').split(/\s+/)[0];
|
|
const cleanDistro = normalizeDistroId(host.distro);
|
|
const cleanManualDistro = normalizeDistroId(host.manualDistro);
|
|
const cleanDistroMode =
|
|
host.distroMode === 'manual'
|
|
? 'manual'
|
|
: host.distroMode === 'auto'
|
|
? 'auto'
|
|
: undefined;
|
|
const migrated = migrateDeprecatedFontOverride(host);
|
|
const cleanNotes = host.notes?.trim() || undefined;
|
|
return {
|
|
...migrated,
|
|
hostname: cleanHostname,
|
|
distro: cleanDistro,
|
|
distroMode: cleanDistroMode,
|
|
manualDistro: cleanManualDistro || undefined,
|
|
notes: cleanNotes,
|
|
};
|
|
};
|