Files
Netcatty/domain/host.ts
2026-06-17 23:32:36 +08:00

346 lines
12 KiB
TypeScript

import { Host, TerminalSettings } from './models';
import { sanitizeHostIconFields } from './hostIcon';
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 cleanHostIcon = sanitizeHostIconFields(host);
const migrated = migrateDeprecatedFontOverride(host);
const cleanNotes = host.notes?.trim() || undefined;
return {
...migrated,
hostname: cleanHostname,
distro: cleanDistro,
distroMode: cleanDistroMode,
manualDistro: cleanManualDistro || undefined,
iconMode: undefined,
iconId: undefined,
iconColor: undefined,
...cleanHostIcon,
notes: cleanNotes,
};
};