Files
NetMesh/domain/vaultHostCreate.ts

954 lines
39 KiB
TypeScript
Raw Permalink Normal View History

import type { GroupConfig, Host, HostProtocol, Identity, ManagedSource, ProxyProfile } from './models';
import { sanitizeHost } from './host';
import type { HostOsSelection } from './models/connection';
import {
findIntroducedVaultJumpGraphIssue,
findVaultGroupConfigJumpReference,
} from './vaultJumpGraph';
const DEFAULT_SSH_PORT = 22;
const DEFAULT_TELNET_PORT = 23;
const UNSAFE_SSH_CONFIG_VALUE = /[\r\n\0]/;
const HOSTNAME_WHITESPACE = /\s/;
const UNSAFE_SSH_JUMP_HOSTNAME = /[\s,@#]/;
const UNSAFE_SSH_JUMP_USERNAME = /[\s,#]/;
const isSafeSshConfigValue = (value: string): boolean =>
!UNSAFE_SSH_CONFIG_VALUE.test(value);
const isSafeSshJumpHostname = (value: string): boolean =>
!value.startsWith('-') && !UNSAFE_SSH_JUMP_HOSTNAME.test(value);
const isSafeSshJumpUsername = (value: string): boolean =>
!value.startsWith('-') && !UNSAFE_SSH_JUMP_USERNAME.test(value);
export type VaultHostDraftProtocol = Exclude<HostProtocol, 'mosh' | 'et' | 'serial'>;
export interface VaultHostDraft {
label?: unknown;
name?: unknown;
hostname?: unknown;
host?: unknown;
ip?: unknown;
port?: unknown;
username?: unknown;
password?: unknown;
savePassword?: unknown;
keyPath?: unknown;
keypath?: unknown;
passphrase?: unknown;
group?: unknown;
tags?: unknown;
notes?: unknown;
protocol?: unknown;
os?: unknown;
}
export interface VaultHostUpdatePatch extends VaultHostDraft {
identityId?: unknown;
jumpHostIds?: unknown;
proxyProfileId?: unknown;
startupCommand?: unknown;
startupCommandRunMode?: unknown;
environmentVariables?: unknown;
moshEnabled?: unknown;
moshServerPath?: unknown;
etEnabled?: unknown;
etPort?: unknown;
serialConfig?: unknown;
}
export interface VaultHostUpdateOptions {
resolveEffectiveHost?: (host: Host) => Host;
groupConfigs?: GroupConfig[];
managedSources?: ManagedSource[];
identities?: Identity[];
proxyProfiles?: ProxyProfile[];
}
export interface VaultHostCreateIssue {
index: number;
error: string;
}
export interface VaultHostKeyPassphrase {
hostId: string;
keyPath: string;
passphrase: string;
}
const normalizeGroupPath = (raw: unknown): string | undefined => {
if (typeof raw !== 'string') return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
const parts = trimmed.replace(/\\/g, '/').split('/').map((part) => part.trim()).filter(Boolean);
return parts.length ? parts.join('/') : undefined;
};
const normalizeProtocol = (raw: unknown): VaultHostDraftProtocol | undefined => {
if (typeof raw !== 'string') return undefined;
const value = raw.trim().toLowerCase();
if (value === 'ssh' || value === 'ssh2') return 'ssh';
if (value === 'telnet') return 'telnet';
if (value === 'local') return 'local';
return undefined;
};
const normalizeOs = (raw: unknown): HostOsSelection | undefined => {
if (typeof raw !== 'string') return undefined;
const value = raw.trim().toLowerCase();
if (value === 'auto' || value === 'freebsd' || value === 'unknown') return value;
if (value === 'linux') return 'linux';
if (value === 'windows' || value === 'win') return 'windows';
if (value === 'macos' || value === 'mac' || value === 'osx' || value === 'darwin') return 'macos';
return undefined;
};
const parsePort = (raw: unknown): number | undefined => {
if (typeof raw === 'number') {
return Number.isInteger(raw) && raw >= 1 && raw <= 65535 ? raw : undefined;
}
if (typeof raw !== 'string') return undefined;
const trimmed = raw.trim();
if (!/^\d+$/u.test(trimmed)) return undefined;
const port = Number(trimmed);
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : undefined;
};
const defaultPortForProtocol = (protocol: HostProtocol | undefined): number =>
protocol === 'telnet' ? DEFAULT_TELNET_PORT : DEFAULT_SSH_PORT;
const supportsSshJump = (host: Host): boolean =>
host.protocol === undefined || host.protocol === 'ssh';
const parseBoolean = (raw: unknown): boolean | undefined => {
if (typeof raw === 'boolean') return raw;
if (typeof raw !== 'string') return undefined;
const normalized = raw.trim().toLowerCase();
if (normalized === 'true' || normalized === '1' || normalized === 'yes') return true;
if (normalized === 'false' || normalized === '0' || normalized === 'no') return false;
return undefined;
};
const normalizeTags = (values: unknown[]): string[] => Array.from(
new Set(values.map((entry) => String(entry).trim()).filter(Boolean)),
);
const parseTags = (
raw: unknown,
): { ok: true; tags: string[] } | { ok: false; error: string } => {
if (raw === undefined || raw === null || raw === '') return { ok: true, tags: [] };
if (Array.isArray(raw)) return { ok: true, tags: normalizeTags(raw) };
if (typeof raw !== 'string') {
return { ok: false, error: 'tags must be an array or comma-separated string.' };
}
const trimmed = raw.trim();
if (!trimmed) return { ok: true, tags: [] };
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed) as unknown;
if (!Array.isArray(parsed)) {
return { ok: false, error: 'tags JSON must be an array.' };
}
return { ok: true, tags: normalizeTags(parsed) };
} catch {
return { ok: false, error: 'tags must be a valid JSON array.' };
}
}
return {
ok: true,
tags: normalizeTags(trimmed.split(/[,;]/g)),
};
};
const hasOwn = (value: object, key: string): boolean =>
Object.prototype.hasOwnProperty.call(value, key);
const firstProvided = (
value: Record<string, unknown>,
keys: string[],
): { provided: boolean; value?: unknown } => {
for (const key of keys) {
if (hasOwn(value, key)) return { provided: true, value: value[key] };
}
return { provided: false };
};
const parseKeyPath = (draft: VaultHostDraft): string | undefined => {
const raw = draft.keyPath ?? draft.keypath;
return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined;
};
/** Endpoint-only identity (protocol/host/port/user). Used for managed ssh_config matching. */
export const buildVaultHostEndpointKey = (
host: Pick<Host, 'hostname' | 'port' | 'username' | 'protocol'>,
): string =>
`${(host.protocol ?? 'ssh').toLowerCase()}|${host.hostname.toLowerCase()}|${host.port}|${(host.username ?? '').toLowerCase()}`;
/**
* Session identity for vault import/create dedupe.
* Same endpoint in a different group is a distinct connection (e.g. direct vs proxy copy).
*/
export const buildVaultHostMergeKey = (
host: Pick<Host, 'hostname' | 'port' | 'username' | 'protocol' | 'group'>,
): string => {
// Keep normalized group spelling (do not case-fold). Vault group paths are
// compared exactly elsewhere, so Prod vs prod are distinct sessions.
const group = normalizeGroupPath(host.group) ?? '';
return `${buildVaultHostEndpointKey(host)}|${group}`;
};
export function buildVaultHostFromDraft(
draft: VaultHostDraft,
): { ok: true; host: Host } | { ok: false; error: string } {
if (!draft || typeof draft !== 'object' || Array.isArray(draft)) {
return { ok: false, error: 'host must be an object.' };
}
const rawHostname = draft.hostname ?? draft.host ?? draft.ip;
const hostname = typeof rawHostname === 'string' ? rawHostname.trim() : '';
if (!hostname) {
return { ok: false, error: 'hostname is required.' };
}
if (!isSafeSshConfigValue(hostname)) {
return { ok: false, error: 'hostname must not contain line breaks or null bytes.' };
}
if (HOSTNAME_WHITESPACE.test(hostname)) {
return { ok: false, error: 'hostname must not contain whitespace.' };
}
const parsedProtocol = normalizeProtocol(draft.protocol);
const hasProtocolInput = draft.protocol !== undefined
&& draft.protocol !== null
&& !(typeof draft.protocol === 'string' && !draft.protocol.trim());
if (hasProtocolInput && parsedProtocol === undefined) {
return { ok: false, error: 'protocol must be ssh, telnet, or local.' };
}
const protocol = parsedProtocol ?? 'ssh';
const parsedOs = normalizeOs(draft.os);
const hasOsInput = draft.os !== undefined
&& draft.os !== null
&& !(typeof draft.os === 'string' && !draft.os.trim());
if (hasOsInput && parsedOs === undefined) {
return { ok: false, error: 'os must be auto, linux, windows, macos, freebsd, or unknown.' };
}
const os = parsedOs === 'windows' || parsedOs === 'macos' ? parsedOs : 'linux';
const parsedPort = parsePort(draft.port);
const hasPortInput = draft.port !== undefined
&& draft.port !== null
&& !(typeof draft.port === 'string' && !draft.port.trim());
if (hasPortInput && parsedPort === undefined) {
return { ok: false, error: 'port must be an integer between 1 and 65535.' };
}
const port = parsedPort ?? defaultPortForProtocol(protocol);
const rawLabel = draft.label ?? draft.name;
if (rawLabel !== undefined && rawLabel !== null && typeof rawLabel !== 'string') {
return { ok: false, error: 'label must be a string.' };
}
const label = typeof rawLabel === 'string' && rawLabel.trim()
? rawLabel.trim()
: hostname;
if (draft.username !== undefined && draft.username !== null && typeof draft.username !== 'string') {
return { ok: false, error: 'username must be a string.' };
}
const username = typeof draft.username === 'string' ? draft.username.trim() : '';
if (!isSafeSshConfigValue(label)) {
return { ok: false, error: 'label must not contain line breaks or null bytes.' };
}
if (!isSafeSshConfigValue(username)) {
return { ok: false, error: 'username must not contain line breaks or null bytes.' };
}
const savePasswordInput = firstProvided(draft as Record<string, unknown>, ['savePassword']);
const savePassword = savePasswordInput.provided
? parseBoolean(savePasswordInput.value)
: undefined;
if (savePasswordInput.provided && savePassword === undefined) {
return { ok: false, error: 'savePassword must be true or false.' };
}
if (draft.password !== undefined && draft.password !== null && typeof draft.password !== 'string') {
return { ok: false, error: 'password must be a string.' };
}
const password = savePassword !== false && typeof draft.password === 'string' && draft.password
? draft.password
: undefined;
const rawKeyPath = draft.keyPath ?? draft.keypath;
if (rawKeyPath !== undefined && rawKeyPath !== null && typeof rawKeyPath !== 'string') {
return { ok: false, error: 'keyPath must be a string.' };
}
const keyPath = parseKeyPath(draft);
if (keyPath && !isSafeSshConfigValue(keyPath)) {
return { ok: false, error: 'keyPath must not contain line breaks or null bytes.' };
}
if (draft.passphrase !== undefined && draft.passphrase !== null && typeof draft.passphrase !== 'string') {
return { ok: false, error: 'passphrase must be a string.' };
}
if (typeof draft.passphrase === 'string' && draft.passphrase && !keyPath) {
return { ok: false, error: 'keyPath is required when passphrase is provided.' };
}
const tags = parseTags(draft.tags);
if (!tags.ok) return tags;
if (draft.group !== undefined && draft.group !== null && typeof draft.group !== 'string') {
return { ok: false, error: 'group must be a string.' };
}
if (draft.notes !== undefined && draft.notes !== null && typeof draft.notes !== 'string') {
return { ok: false, error: 'notes must be a string.' };
}
const notes = typeof draft.notes === 'string' && draft.notes.trim() ? draft.notes.trim() : undefined;
const now = Date.now();
return {
ok: true,
host: {
id: crypto.randomUUID(),
label,
hostname,
port,
username,
password,
...(savePassword !== undefined ? { savePassword } : {}),
group: normalizeGroupPath(draft.group),
tags: tags.tags,
os,
osOverride: parsedOs ?? 'auto',
protocol,
createdAt: now,
...(keyPath
? {
identityFilePaths: [keyPath],
authMethod: 'key' as const,
authPolicyVersion: 1 as const,
useSshAgent: false,
}
: {}),
...(notes ? { notes } : {}),
},
};
}
export function applyVaultHostUpdate(
existingHosts: Host[],
existingGroups: string[],
hostId: string,
patch: VaultHostUpdatePatch,
options: VaultHostUpdateOptions = {},
): {
ok: true;
hosts: Host[];
customGroups: string[];
updatedHost: Host;
} | { ok: false; error: string } {
const hostIndex = existingHosts.findIndex((host) => host.id === hostId);
if (hostIndex < 0) return { ok: false, error: `Host "${hostId}" was not found.` };
const source = patch as Record<string, unknown>;
const label = firstProvided(source, ['label', 'name']);
const hostname = firstProvided(source, ['hostname', 'host', 'ip']);
const port = firstProvided(source, ['port']);
const username = firstProvided(source, ['username']);
const password = firstProvided(source, ['password']);
const savePassword = firstProvided(source, ['savePassword']);
const keyPath = firstProvided(source, ['keyPath', 'keypath']);
const group = firstProvided(source, ['group']);
const tags = firstProvided(source, ['tags']);
const notes = firstProvided(source, ['notes']);
const protocol = firstProvided(source, ['protocol']);
const os = firstProvided(source, ['os']);
const identityId = firstProvided(source, ['identityId']);
const jumpHostIds = firstProvided(source, ['jumpHostIds']);
const proxyProfileId = firstProvided(source, ['proxyProfileId']);
const startupCommand = firstProvided(source, ['startupCommand']);
const startupCommandRunMode = firstProvided(source, ['startupCommandRunMode']);
const environmentVariables = firstProvided(source, ['environmentVariables']);
const moshEnabled = firstProvided(source, ['moshEnabled']);
const moshServerPath = firstProvided(source, ['moshServerPath']);
const etEnabled = firstProvided(source, ['etEnabled']);
const etPort = firstProvided(source, ['etPort']);
const serialConfig = firstProvided(source, ['serialConfig']);
const provided = [label, hostname, port, username, password, savePassword, keyPath, group, tags, notes, protocol, os,
identityId, jumpHostIds, proxyProfileId, startupCommand, startupCommandRunMode, environmentVariables,
moshEnabled, moshServerPath, etEnabled, etPort, serialConfig]
.some((entry) => entry.provided);
if (!provided) return { ok: false, error: 'At least one host field is required.' };
const current = existingHosts[hostIndex];
let updated: Host = { ...current };
if (label.provided) {
if (typeof label.value !== 'string' || !label.value.trim()) {
return { ok: false, error: 'label must not be empty.' };
}
if (!isSafeSshConfigValue(label.value)) {
return { ok: false, error: 'label must not contain line breaks or null bytes.' };
}
updated.label = label.value.trim();
}
if (hostname.provided) {
if (typeof hostname.value !== 'string' || !hostname.value.trim()) {
return { ok: false, error: 'hostname must not be empty.' };
}
if (!isSafeSshConfigValue(hostname.value)) {
return { ok: false, error: 'hostname must not contain line breaks or null bytes.' };
}
const requestedProtocol = protocol.provided
? String(protocol.value ?? '').trim().toLowerCase()
: current.protocol;
if (requestedProtocol !== 'serial' && HOSTNAME_WHITESPACE.test(hostname.value.trim())) {
return { ok: false, error: 'hostname must not contain whitespace.' };
}
updated.hostname = hostname.value.trim();
}
if (port.provided) {
const parsedPort = parsePort(port.value);
if (parsedPort === undefined) {
return { ok: false, error: 'port must be an integer between 1 and 65535.' };
}
updated.port = parsedPort;
}
if (group.provided) {
if (typeof group.value !== 'string') {
return { ok: false, error: 'group must be a string.' };
}
updated.group = normalizeGroupPath(group.value);
}
if (protocol.provided) {
const rawProtocol = typeof protocol.value === 'string' ? protocol.value.trim().toLowerCase() : '';
const nextProtocol = rawProtocol === 'serial' ? 'serial' : normalizeProtocol(protocol.value);
if (!nextProtocol) {
return { ok: false, error: 'protocol must be ssh, telnet, local, or serial.' };
}
if (current.protocol === 'serial' && nextProtocol !== 'serial' && !hostname.provided) {
return { ok: false, error: 'hostname is required when changing a serial host to a network protocol.' };
}
if (
nextProtocol !== 'serial'
&& !port.provided
&& (
current.protocol === 'serial'
|| current.port === undefined
|| current.port === defaultPortForProtocol(current.protocol)
)
) {
updated.port = defaultPortForProtocol(nextProtocol);
}
updated.protocol = nextProtocol;
if (nextProtocol !== 'ssh') {
updated.moshEnabled = false;
updated.etEnabled = false;
}
}
if (os.provided) {
const nextOs = normalizeOs(os.value);
if (!nextOs) {
return { ok: false, error: 'os must be auto, linux, windows, macos, freebsd, or unknown.' };
}
updated.osOverride = nextOs;
updated.os = nextOs === 'windows' || nextOs === 'macos' ? nextOs : 'linux';
}
if (identityId.provided) {
if (typeof identityId.value !== 'string') return { ok: false, error: 'identityId must be a string.' };
const nextIdentityId = identityId.value.trim();
const identity = options.identities?.find((item) => item.id === nextIdentityId);
if (nextIdentityId && !identity) return { ok: false, error: `Identity "${nextIdentityId}" was not found.` };
updated.identityId = nextIdentityId;
if (identity) {
updated.username = identity.username;
updated.authMethod = identity.authMethod;
updated.authPolicyVersion = 1;
updated.password = undefined;
updated.identityFileId = undefined;
updated.identityFilePaths = undefined;
updated.useSshAgent = false;
} else if (current.identityId) {
updated.authMethod = 'auto';
updated.authPolicyVersion = 1;
updated.identityFileId = undefined;
updated.identityFilePaths = undefined;
updated.useSshAgent = undefined;
}
}
if (jumpHostIds.provided) {
let ids: unknown = jumpHostIds.value;
if (typeof ids === 'string') {
try { ids = JSON.parse(ids); } catch { return { ok: false, error: 'jumpHostIds must be a valid JSON array.' }; }
}
if (!Array.isArray(ids)) return { ok: false, error: 'jumpHostIds must be an array.' };
const normalizedIds = ids.map(String).map((id) => id.trim()).filter(Boolean);
if (new Set(normalizedIds).size !== normalizedIds.length) return { ok: false, error: 'jumpHostIds must not contain duplicates.' };
if (normalizedIds.includes(hostId)) return { ok: false, error: 'A host cannot use itself as a jump host.' };
const missing = normalizedIds.find((id) => !existingHosts.some((candidate) => candidate.id === id));
if (missing) return { ok: false, error: `Jump host "${missing}" was not found.` };
const unsupported = normalizedIds.find((id) => {
const candidate = existingHosts.find((host) => host.id === id);
if (!candidate) return false;
const effectiveCandidate = options.resolveEffectiveHost?.(candidate) ?? candidate;
return !supportsSshJump(effectiveCandidate);
});
if (unsupported) return { ok: false, error: `Jump host "${unsupported}" does not support SSH jump connections.` };
updated.hostChain = { hostIds: normalizedIds };
}
if (proxyProfileId.provided) {
if (typeof proxyProfileId.value !== 'string') return { ok: false, error: 'proxyProfileId must be a string.' };
const nextProxyProfileId = proxyProfileId.value.trim();
if (nextProxyProfileId && !options.proxyProfiles?.some((profile) => profile.id === nextProxyProfileId)) {
return { ok: false, error: `Proxy profile "${nextProxyProfileId}" was not found.` };
}
updated.proxyProfileId = nextProxyProfileId;
updated.proxyConfig = undefined;
}
if (startupCommand.provided) {
if (typeof startupCommand.value !== 'string') return { ok: false, error: 'startupCommand must be a string.' };
updated.startupCommand = startupCommand.value;
}
if (startupCommandRunMode.provided) {
const mode = String(startupCommandRunMode.value ?? '');
if (mode !== 'paste' && mode !== 'lineDelay' && mode !== '') return { ok: false, error: 'startupCommandRunMode must be paste or lineDelay.' };
updated.startupCommandRunMode = mode === 'lineDelay' ? 'lineDelay' : undefined;
}
if (environmentVariables.provided) {
let raw: unknown = environmentVariables.value;
if (typeof raw === 'string') {
try { raw = JSON.parse(raw); } catch { return { ok: false, error: 'environmentVariables must be valid JSON.' }; }
}
if (Array.isArray(raw)) {
const entries = raw.map((item) => item as Record<string, unknown>);
if (entries.some((item) => typeof item?.name !== 'string')) return { ok: false, error: 'environmentVariables array entries require name and value.' };
updated.environmentVariables = entries.map((item) => ({ name: String(item.name), value: String(item.value ?? '') }));
} else if (raw && typeof raw === 'object') {
updated.environmentVariables = Object.entries(raw as Record<string, unknown>).map(([name, value]) => ({ name, value: String(value ?? '') }));
} else return { ok: false, error: 'environmentVariables must be a JSON object or array.' };
}
const nextMoshEnabled = moshEnabled.provided ? parseBoolean(moshEnabled.value) : undefined;
const nextEtEnabled = etEnabled.provided ? parseBoolean(etEnabled.value) : undefined;
if (moshEnabled.provided && nextMoshEnabled === undefined) {
return { ok: false, error: 'moshEnabled must be true or false.' };
}
if (etEnabled.provided && nextEtEnabled === undefined) {
return { ok: false, error: 'etEnabled must be true or false.' };
}
if (nextMoshEnabled === true && nextEtEnabled === true) {
return { ok: false, error: 'Mosh and ET cannot both be enabled.' };
}
if (moshEnabled.provided) {
updated.moshEnabled = nextMoshEnabled;
if (nextMoshEnabled) updated.etEnabled = false;
}
if (etEnabled.provided) {
updated.etEnabled = nextEtEnabled;
if (nextEtEnabled) updated.moshEnabled = false;
}
if (updated.protocol !== undefined && updated.protocol !== 'ssh' && (updated.moshEnabled || updated.etEnabled)) {
return { ok: false, error: 'Mosh and ET require the SSH protocol.' };
}
if (moshServerPath.provided) updated.moshServerPath = String(moshServerPath.value ?? '') || undefined;
if (etPort.provided) {
const value = parsePort(etPort.value);
if (value === undefined) return { ok: false, error: 'etPort must be an integer between 1 and 65535.' };
updated.etPort = value;
}
if (serialConfig.provided) {
let raw: unknown = serialConfig.value;
if (typeof raw === 'string') {
try { raw = JSON.parse(raw); } catch { return { ok: false, error: 'serialConfig must be valid JSON.' }; }
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, error: 'serialConfig must be a JSON object.' };
const config = raw as Record<string, unknown>;
const path = String(config.path ?? '').trim();
const baudRate = Number(config.baudRate);
if (!path || !Number.isInteger(baudRate) || baudRate <= 0) return { ok: false, error: 'serialConfig requires path and a positive baudRate.' };
if (!isSafeSshConfigValue(path)) {
return { ok: false, error: 'serialConfig.path must not contain line breaks or null bytes.' };
}
const dataBits = config.dataBits === undefined ? undefined : Number(config.dataBits);
if (dataBits !== undefined && ![5, 6, 7, 8].includes(dataBits)) return { ok: false, error: 'serialConfig.dataBits must be 5, 6, 7, or 8.' };
const stopBits = config.stopBits === undefined ? undefined : Number(config.stopBits);
if (stopBits !== undefined && ![1, 1.5, 2].includes(stopBits)) return { ok: false, error: 'serialConfig.stopBits must be 1, 1.5, or 2.' };
const parity = config.parity === undefined ? undefined : String(config.parity);
if (parity !== undefined && !['none', 'even', 'odd', 'mark', 'space'].includes(parity)) return { ok: false, error: 'serialConfig.parity is invalid.' };
const flowControl = config.flowControl === undefined ? undefined : String(config.flowControl);
if (flowControl !== undefined && !['none', 'xon/xoff', 'rts/cts'].includes(flowControl)) return { ok: false, error: 'serialConfig.flowControl is invalid.' };
const localEcho = config.localEcho === undefined ? undefined : parseBoolean(config.localEcho);
if (config.localEcho !== undefined && localEcho === undefined) return { ok: false, error: 'serialConfig.localEcho must be true or false.' };
const lineMode = config.lineMode === undefined ? undefined : parseBoolean(config.lineMode);
if (config.lineMode !== undefined && lineMode === undefined) return { ok: false, error: 'serialConfig.lineMode must be true or false.' };
const rawBackspaceBehavior = config.backspaceBehavior;
const backspaceBehavior = rawBackspaceBehavior === undefined
? updated.serialConfig?.backspaceBehavior
: String(rawBackspaceBehavior);
if (backspaceBehavior !== undefined && !['default', 'ctrl-h'].includes(backspaceBehavior)) {
return { ok: false, error: 'serialConfig.backspaceBehavior must be default or ctrl-h.' };
}
updated.serialConfig = {
path,
baudRate,
...(dataBits !== undefined ? { dataBits: dataBits as 5 | 6 | 7 | 8 } : {}),
...(stopBits !== undefined ? { stopBits: stopBits as 1 | 1.5 | 2 } : {}),
...(parity !== undefined ? { parity: parity as 'none' | 'even' | 'odd' | 'mark' | 'space' } : {}),
...(flowControl !== undefined ? { flowControl: flowControl as 'none' | 'xon/xoff' | 'rts/cts' } : {}),
...(localEcho !== undefined ? { localEcho } : {}),
...(lineMode !== undefined ? { lineMode } : {}),
...(backspaceBehavior !== undefined ? { backspaceBehavior: backspaceBehavior as 'default' | 'ctrl-h' } : {}),
};
}
if (updated.protocol === 'serial' && updated.serialConfig) {
if (serialConfig.provided && hostname.provided && updated.hostname !== updated.serialConfig.path) {
return { ok: false, error: 'hostname and serialConfig.path must match for serial hosts.' };
}
if (hostname.provided && !serialConfig.provided) {
updated.serialConfig = { ...updated.serialConfig, path: updated.hostname };
} else {
updated.hostname = updated.serialConfig.path;
}
updated.port = updated.serialConfig.baudRate;
}
if (
protocol.provided
&& current.protocol !== 'serial'
&& updated.protocol === 'serial'
&& !updated.serialConfig
) {
return { ok: false, error: 'serialConfig is required when protocol is serial.' };
}
const effectiveBeforeSavePassword = options.resolveEffectiveHost?.(updated) ?? updated;
if (effectiveBeforeSavePassword.moshEnabled && effectiveBeforeSavePassword.etEnabled) {
return { ok: false, error: 'Mosh and ET cannot both be enabled.' };
}
if (
effectiveBeforeSavePassword.protocol !== undefined
&& effectiveBeforeSavePassword.protocol !== 'ssh'
&& (effectiveBeforeSavePassword.moshEnabled || effectiveBeforeSavePassword.etEnabled)
) {
return { ok: false, error: 'Mosh and ET require the SSH protocol.' };
}
if (savePassword.provided) {
const nextSavePassword = parseBoolean(savePassword.value);
if (nextSavePassword === undefined) {
return { ok: false, error: 'savePassword must be true or false.' };
}
updated.savePassword = nextSavePassword;
if (!nextSavePassword) updated.password = undefined;
}
const effectiveCurrent = options.resolveEffectiveHost?.(updated) ?? updated;
const selectedIdentityId = effectiveCurrent.identityId ?? effectiveBeforeSavePassword.identityId;
const selectedIdentity = selectedIdentityId
? options.identities?.find((identity) => identity.id === selectedIdentityId)
: undefined;
if (username.provided) {
if (typeof username.value !== 'string') {
return { ok: false, error: 'username must be a string.' };
}
if (!isSafeSshConfigValue(username.value)) {
return { ok: false, error: 'username must not contain line breaks or null bytes.' };
}
updated.username = username.value.trim();
if (selectedIdentityId) {
updated.identityId = '';
if (selectedIdentity?.authMethod === 'password') {
if (effectiveCurrent.savePassword !== false) {
updated.password = selectedIdentity.password;
}
updated.authMethod = 'password';
updated.authPolicyVersion = 1;
} else if (selectedIdentity?.keyId) {
updated.identityFileId = selectedIdentity.keyId;
updated.authMethod = selectedIdentity.authMethod;
updated.authPolicyVersion = 1;
updated.useSshAgent = false;
}
}
}
if (savePassword.provided && updated.savePassword === false && !username.provided && selectedIdentity) {
if (selectedIdentity.authMethod === 'password') {
updated.identityId = '';
updated.username = selectedIdentity.username;
updated.authMethod = 'password';
updated.authPolicyVersion = 1;
} else {
updated.identityId = selectedIdentity.id;
updated.authMethod = selectedIdentity.authMethod;
updated.authPolicyVersion = 1;
}
}
if (password.provided) {
if (typeof password.value !== 'string') {
return { ok: false, error: 'password must be a string.' };
}
if (password.value && effectiveCurrent.savePassword === false) {
return {
ok: false,
error: 'This host is configured not to save passwords. Enable password saving before updating it.',
};
}
updated.password = password.value || undefined;
if (!password.value) {
updated.savePassword = false;
}
const keyPathIsEmpty = keyPath.provided
&& typeof keyPath.value === 'string'
&& !keyPath.value.trim();
if (password.value && keyPathIsEmpty) {
if (selectedIdentity && !username.provided) {
updated.username = selectedIdentity.username;
}
updated.authMethod = 'password';
updated.authPolicyVersion = 1;
updated.identityId = '';
updated.identityFileId = undefined;
updated.identityFilePaths = undefined;
updated.useSshAgent = false;
} else if (selectedIdentityId) {
if (selectedIdentity?.authMethod === 'password') {
updated.identityId = '';
updated.username = username.provided
? updated.username
: selectedIdentity.username;
updated.authMethod = 'password';
updated.authPolicyVersion = 1;
} else if (selectedIdentity && !username.provided) {
updated.identityId = selectedIdentity.id;
updated.authMethod = selectedIdentity.authMethod;
updated.authPolicyVersion = 1;
}
}
}
if (keyPath.provided) {
if (typeof keyPath.value !== 'string') {
return { ok: false, error: 'keyPath must be a string.' };
}
const nextKeyPath = keyPath.value.trim();
if (!isSafeSshConfigValue(nextKeyPath)) {
return { ok: false, error: 'keyPath must not contain line breaks or null bytes.' };
}
updated.identityFilePaths = nextKeyPath ? [nextKeyPath] : [];
if (nextKeyPath) {
updated.identityFileId = undefined;
updated.identityId = '';
updated.authMethod = 'key';
updated.authPolicyVersion = 1;
updated.useSshAgent = false;
} else if (
!updated.identityId
&& !updated.identityFileId
&& !effectiveCurrent.identityId
&& !effectiveCurrent.identityFileId
&& updated.authMethod !== 'password'
&& effectiveCurrent.authMethod === 'key'
) {
updated.authMethod = 'auto';
updated.authPolicyVersion = 1;
updated.useSshAgent = undefined;
}
}
if (tags.provided) {
const nextTags = parseTags(tags.value);
if (!nextTags.ok) return nextTags;
updated.tags = nextTags.tags;
}
if (notes.provided) {
if (typeof notes.value !== 'string') {
return { ok: false, error: 'notes must be a string.' };
}
updated.notes = notes.value.trim() || undefined;
}
if (options.managedSources) {
const targetManagedSource = options.managedSources
.filter((sourceInfo) => (
updated.group === sourceInfo.groupName
|| updated.group?.startsWith(`${sourceInfo.groupName}/`)
))
.sort((a, b) => b.groupName.length - a.groupName.length)[0];
const canBeManaged = !updated.protocol || updated.protocol === 'ssh';
if (targetManagedSource && canBeManaged) {
for (const jumpHostId of updated.hostChain?.hostIds ?? []) {
const jumpHost = existingHosts.find((candidate) => candidate.id === jumpHostId);
if (!jumpHost) continue;
if (!isSafeSshJumpHostname(jumpHost.hostname)) {
return { ok: false, error: 'hostname contains characters that are unsafe for an SSH jump host.' };
}
if (jumpHost.username && !isSafeSshJumpUsername(jumpHost.username)) {
return { ok: false, error: 'username contains characters that are unsafe for an SSH jump host.' };
}
}
if (label.provided || current.managedSourceId !== targetManagedSource.id) {
updated.label = updated.label.replace(/\s/g, '');
}
updated.managedSourceId = targetManagedSource.id;
} else if (options.managedSources.length > 0 || !canBeManaged) {
updated.managedSourceId = undefined;
}
const managedSourceIds = new Set(options.managedSources.map((sourceInfo) => sourceInfo.id));
const isManagedJumpHost = existingHosts.some((candidate) => (
candidate.id !== current.id
&& candidate.managedSourceId
&& managedSourceIds.has(candidate.managedSourceId)
&& (!candidate.protocol || candidate.protocol === 'ssh')
&& candidate.hostChain?.hostIds?.includes(current.id)
));
if (isManagedJumpHost) {
if (!isSafeSshJumpHostname(updated.hostname)) {
return { ok: false, error: 'hostname contains characters that are unsafe for an SSH jump host.' };
}
if (updated.username && !isSafeSshJumpUsername(updated.username)) {
return { ok: false, error: 'username contains characters that are unsafe for an SSH jump host.' };
}
}
}
updated = sanitizeHost(updated);
if (updated.protocol === 'serial' && updated.serialConfig) {
updated.hostname = updated.serialConfig.path;
}
const hosts = [...existingHosts];
hosts[hostIndex] = updated;
const jumpGraphIssue = findIntroducedVaultJumpGraphIssue(
existingHosts,
hosts,
options.resolveEffectiveHost,
);
if (jumpGraphIssue) {
if (jumpGraphIssue.kind === 'protocol' && jumpGraphIssue.jumpHostId === current.id) {
return { ok: false, error: 'A host used as a jump host must keep an SSH connection type.' };
}
return { ok: false, error: jumpGraphIssue.error };
}
const groupConfigReference = findVaultGroupConfigJumpReference(
options.groupConfigs ?? [],
current.id,
);
if (groupConfigReference) {
const effectiveBefore = options.resolveEffectiveHost?.(current) ?? current;
const effectiveAfter = options.resolveEffectiveHost?.(updated) ?? updated;
if (supportsSshJump(effectiveBefore) && !supportsSshJump(effectiveAfter)) {
return { ok: false, error: 'A host used as a group jump host must keep an SSH connection type.' };
}
}
const customGroups = updated.group
? Array.from(new Set([...existingGroups, updated.group]))
: [...existingGroups];
return { ok: true, hosts, customGroups, updatedHost: updated };
}
export function applyVaultHostDelete(
existingHosts: Host[],
hostId: string,
resolveEffectiveHost?: (host: Host) => Host,
groupConfigs: GroupConfig[] = [],
): { ok: true; hosts: Host[]; deletedHost: Host } | { ok: false; error: string } {
const deletedHost = existingHosts.find((host) => host.id === hostId);
if (!deletedHost) return { ok: false, error: `Host "${hostId}" was not found.` };
if (findVaultGroupConfigJumpReference(groupConfigs, hostId)) {
return { ok: false, error: `Host "${hostId}" is still used as a group jump host.` };
}
const hosts = existingHosts.filter((host) => host.id !== hostId);
const jumpGraphIssue = findIntroducedVaultJumpGraphIssue(
existingHosts,
hosts,
resolveEffectiveHost,
);
if (jumpGraphIssue?.kind === 'missing' && jumpGraphIssue.jumpHostId === hostId) {
return { ok: false, error: `Host "${hostId}" is still used as a jump host.` };
}
if (jumpGraphIssue) return { ok: false, error: jumpGraphIssue.error };
return {
ok: true,
hosts,
deletedHost,
};
}
export function parseVaultHostDraftsInput(
value: unknown,
): { ok: true; drafts: VaultHostDraft[] } | { ok: false; error: string } {
let parsed: unknown = value;
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return { ok: false, error: 'hosts is required.' };
try {
parsed = JSON.parse(trimmed) as unknown;
} catch {
return { ok: false, error: 'hosts must be a JSON array string.' };
}
}
if (!Array.isArray(parsed)) {
return { ok: false, error: 'hosts must be a JSON array of host objects.' };
}
if (parsed.length === 0) {
return { ok: false, error: 'hosts array is empty.' };
}
return { ok: true, drafts: parsed as VaultHostDraft[] };
}
export function buildVaultHostsFromDrafts(
drafts: VaultHostDraft[],
): { hosts: Host[]; issues: VaultHostCreateIssue[]; keyPassphrases: VaultHostKeyPassphrase[] } {
const hosts: Host[] = [];
const issues: VaultHostCreateIssue[] = [];
const keyPassphrases: VaultHostKeyPassphrase[] = [];
drafts.forEach((draft, index) => {
const built = buildVaultHostFromDraft(draft);
if (!built.ok) {
issues.push({ index, error: built.error });
return;
}
hosts.push(built.host);
const keyPath = parseKeyPath(draft);
if (keyPath && typeof draft.passphrase === 'string' && draft.passphrase) {
keyPassphrases.push({ hostId: built.host.id, keyPath, passphrase: draft.passphrase });
}
});
return { hosts, issues, keyPassphrases };
}
export function applyVaultHostCreates(
existingHosts: Host[],
existingGroups: string[],
createdHosts: Host[],
options?: { skipDuplicates?: boolean },
): {
hosts: Host[];
customGroups: string[];
addedCount: number;
skippedExistingCount: number;
addedHosts: Host[];
} {
const skipDuplicates = options?.skipDuplicates !== false;
const existingKeys = new Set(existingHosts.map(buildVaultHostMergeKey));
let newHosts = createdHosts;
let skippedExistingCount = 0;
if (skipDuplicates) {
newHosts = createdHosts.filter((host) => {
const duplicate = existingKeys.has(buildVaultHostMergeKey(host));
if (duplicate) skippedExistingCount++;
return !duplicate;
});
}
const customGroups = Array.from(
new Set([
...existingGroups,
...newHosts.map((host) => host.group).filter(Boolean),
]),
) as string[];
return {
hosts: [...existingHosts, ...newHosts].map(sanitizeHost),
customGroups,
addedCount: newHosts.length,
skippedExistingCount,
addedHosts: newHosts,
};
}