Files
NetMesh/domain/vaultHostCreate.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

954 lines
39 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}