import { Host, Snippet, TerminalSettings } from './models'; import type { HostOperatingSystem, HostOsSelection } from './models/connection'; import { sanitizeHostIconFields } from './hostIcon'; import { migrateHostConnectScriptIds } from './hostConnectScripts.ts'; import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts'; import { sanitizeOptionalSshTimeoutSeconds } from './sshConnectionTimeouts.ts'; import { isPluginHostProtocol, sanitizePluginConnection, stripBuiltInConnectionFieldsForPluginHost, } from './pluginConnection.ts'; 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; export const POSIX_PLATFORM_OPTIONS = [ 'macos', 'freebsd', ] as const; // Only platforms backed by the current stats and system-management commands // belong here. Other POSIX platforms may still be valid icon choices. const LINUX_LIKE_RUNTIME_PLATFORM_OPTIONS = [ 'macos', ] 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', 'h3c', '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 === 'darwin' || v === 'macos' || v === 'mac os' || v === 'mac os x' || v.includes('darwin kernel') || v.includes('macos') || v.includes('mac os') ) { return 'macos'; } if (v === 'windows' || v === 'win32' || /^openssh_for_windows(?:_|$)/.test(v)) return 'windows'; if (v.includes('freebsd')) return 'freebsd'; 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--\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'; // H3C / 3Com Comware switches if (/^H3C[-_\s]/i.test(s)) return 'h3c'; if (/^Comware-/i.test(s)) return 'h3c'; if (/^3Com\s*OS/i.test(s)) return 'h3c'; // HPE iLO 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 = normalizeDistroId(distroId) || (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'; if ((LINUX_LIKE_RUNTIME_PLATFORM_OPTIONS as readonly string[]).includes(v)) return 'linux-like'; return 'other'; }; export const HOST_OS_SELECTIONS = ['auto', 'linux', 'windows', 'macos', 'freebsd', 'unknown'] as const; export function getHostOsSelection(host?: Pick | null): HostOsSelection { if (host?.osOverride && (HOST_OS_SELECTIONS as readonly string[]).includes(host.osOverride)) { return host.osOverride; } return host?.os === 'windows' || host?.os === 'macos' ? host.os : 'auto'; } /** Resolve system facts separately from cosmetic manualDistro/icon choices. */ export function resolveHostOs( host?: Pick | null, ): HostOperatingSystem { if (host?.protocol === 'local') return host.os; const selection = getHostOsSelection(host); if (selection !== 'auto') return selection; if (host?.deviceType === 'network' || classifyDistroId(host?.distro) === 'network-device') return 'unknown'; const distro = normalizeDistroId(host?.distro); if (distro === 'windows' || distro === 'macos' || distro === 'freebsd') return distro; if ((LINUX_DISTRO_OPTIONS as readonly string[]).includes(distro)) return 'linux'; return 'unknown'; } /** * Decide whether to offer the "enable Network Device Mode" suggestion after * distro/vendor detection. We only nag once per host, and never when the user * has already turned the mode on. The detected value must classify as a * network-device vendor (exact match — see `classifyDistroId`), so a normal * Linux distro whose name merely embeds a vendor keyword never triggers it. */ export const shouldSuggestNetworkDeviceMode = (opts: { host?: Pick | null; detectedDistro?: string; alreadyHandled?: boolean; /** * Effective session protocol (mosh/et folded in). The suggestion — and its * host-level `deviceType: 'network'` write — must match the Host Details * "Network Device Mode" toggle, which is only exposed for plain SSH sessions * (not Mosh/ET/serial/telnet/local). Offering it elsewhere would let the user * persist a hidden host-level mode that surprises later SSH reconnects (#2367). */ effectiveProtocol?: string; }): boolean => { if (!opts.host) return false; if (opts.host.deviceType === 'network') return false; if (opts.alreadyHandled) return false; if (opts.effectiveProtocol !== undefined && opts.effectiveProtocol !== 'ssh') { return false; } return classifyDistroId(opts.detectedDistro) === 'network-device'; }; /** * 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 | 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}`; }; /** Hostname/IP text for one-click clipboard copy from the vault host list. */ export const getHostAddressForClipboard = ( host: Pick, ): string => { // Plugin endpoints live in pluginConnection.configuration; hostname may be a label/provider id. if (isPluginHostProtocol(host.protocol)) return ''; return String(host.hostname ?? '').trim(); }; export const resolveTelnetUsername = ( host: Pick, ): string | undefined => host.telnetUsername !== undefined ? host.telnetUsername.trim() : host.username?.trim(); export const resolveTelnetPassword = ( host: Pick, ): string | undefined => host.telnetPassword !== undefined ? host.telnetPassword : host.password; export const resolveTelnetPort = ( host: Pick, ): 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; let next = draft; if ( draft.showLineTimestamps === openedHost.showLineTimestamps && latestHost.showLineTimestamps !== openedHost.showLineTimestamps ) { next = { ...next, showLineTimestamps: latestHost.showLineTimestamps }; } if ( draft.sftpFollowTerminalCwd === openedHost.sftpFollowTerminalCwd && latestHost.sftpFollowTerminalCwd !== openedHost.sftpFollowTerminalCwd ) { next = { ...next, sftpFollowTerminalCwd: latestHost.sftpFollowTerminalCwd }; } return next; }; 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, globalSettings: Pick, ): 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', }; }; /** * True when two host records are interchangeable for React identity reuse. * Uses shallow key comparison (nested objects compared by reference). */ export const hostsEqualForIdentityReuse = (a: Host, b: Host): boolean => { if (a === b) return true; const aKeys = Object.keys(a) as (keyof Host)[]; const bKeys = Object.keys(b) as (keyof Host)[]; if (aKeys.length !== bKeys.length) return false; for (const key of aKeys) { if (a[key] !== b[key]) return false; } for (const key of bKeys) { if (a[key] !== b[key]) return false; } return true; }; export const sanitizeHost = (host: Host, snippets: Snippet[] = []): Host => { const cleanHostname = (host.hostname || '').trim().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 cleanHostIcon = sanitizeHostIconFields(host); const osSelection = getHostOsSelection(host); const migrated = migrateDeprecatedFontOverride(host); // Before explicit per-host authentication modes existed, new hosts were // persisted with authMethod="password" even though an empty password still // allowed the ambient agent and local keys. Preserve that behavior once on // load; versioned records represent a deliberate Password-only selection. const isLegacyPasswordDefault = migrated.authPolicyVersion !== 1 && migrated.authMethod === 'password' && ( migrated.useSshAgent === true || Boolean(migrated.identityFileId) || Boolean(migrated.identityFilePaths?.length) || (migrated.savePassword !== false && !migrated.password?.length) ); const inferredLegacyAuthMethod = migrated.authPolicyVersion !== 1 && migrated.authMethod === undefined && migrated.useSshAgent !== true ? migrated.identityFilePaths?.length ? 'key' : !migrated.identityFileId && migrated.password?.length ? 'password' : undefined : undefined; const cleanNotes = host.notes?.trim() || undefined; const connectScriptIds = host.connectScriptIds ?? ( snippets.length > 0 ? migrateHostConnectScriptIds(host, snippets) : host.loginScriptId ? [host.loginScriptId] : undefined ); const pluginConnection = sanitizePluginConnection(host.pluginConnection, host.protocol); const sanitized: Host = { ...migrated, authMethod: isLegacyPasswordDefault ? undefined : migrated.authMethod ?? inferredLegacyAuthMethod, authPolicyVersion: 1, hostname: cleanHostname, osOverride: osSelection, os: osSelection === 'linux' || osSelection === 'windows' || osSelection === 'macos' ? osSelection : host.os, distro: cleanDistro, distroMode: cleanDistroMode, manualDistro: cleanManualDistro || undefined, iconMode: undefined, iconId: undefined, iconColorMode: undefined, iconColor: undefined, iconColorCustom: undefined, ...cleanHostIcon, notes: cleanNotes, sshTcpConnectTimeoutSeconds: sanitizeOptionalSshTimeoutSeconds( host.sshTcpConnectTimeoutSeconds, ), sshAuthReadyTimeoutSeconds: sanitizeOptionalSshTimeoutSeconds( host.sshAuthReadyTimeoutSeconds, ), connectScriptIds: connectScriptIds && connectScriptIds.length > 0 ? connectScriptIds : undefined, pluginConnection, }; return stripBuiltInConnectionFieldsForPluginHost(sanitized); };