Files
NetMesh/domain/host.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

520 lines
19 KiB
TypeScript

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-<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';
// 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<Host, 'os' | 'osOverride'> | 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<Host, 'os' | 'osOverride' | 'distro' | 'deviceType' | 'protocol'> | 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<Host, 'deviceType'> | 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<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}`;
};
/** Hostname/IP text for one-click clipboard copy from the vault host list. */
export const getHostAddressForClipboard = (
host: Pick<Host, 'hostname' | 'protocol'>,
): 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<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;
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<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',
};
};
/**
* 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);
};