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
1508 lines
48 KiB
TypeScript
1508 lines
48 KiB
TypeScript
import { Host, HostChainConfig, HostProtocol } from "./models";
|
||
import { isEncryptedCredentialPlaceholder } from "./credentials";
|
||
import { sanitizeHost } from "./host";
|
||
import { hasMacKeychainAgentDirectives } from "./sshAuth";
|
||
import {
|
||
buildVaultHostFromDraft,
|
||
buildVaultHostMergeKey,
|
||
type VaultHostKeyPassphrase,
|
||
type VaultHostDraftProtocol,
|
||
} from "./vaultHostCreate";
|
||
|
||
export {
|
||
buildVaultHostEndpointKey,
|
||
buildVaultHostMergeKey,
|
||
} from "./vaultHostCreate";
|
||
import { parseQuickConnectInput } from "./quickConnect";
|
||
import { findExactHeaderIndex, findHeaderIndex, parseCsv } from "./vaultImport/csvUtils";
|
||
import { decodeCsvKeyPath, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
|
||
import { attachMobaXtermPasswords } from "./vaultImport/mobaXtermPasswords";
|
||
export {
|
||
exportHostsToCsvWithStats,
|
||
getVaultCsvTemplate,
|
||
resolveVaultCsvHostKeyPath,
|
||
} from "./vaultImport/csvExport";
|
||
|
||
interface ParsedJumpHost {
|
||
hostname: string;
|
||
username?: string;
|
||
port?: number;
|
||
}
|
||
|
||
const parseJumpHostSpec = (spec: string): ParsedJumpHost | null => {
|
||
const trimmed = spec.trim();
|
||
if (!trimmed || trimmed.toLowerCase() === "none") return null;
|
||
|
||
if (trimmed.startsWith("ssh://")) {
|
||
try {
|
||
const url = new URL(trimmed);
|
||
return {
|
||
hostname: url.hostname,
|
||
username: url.username || undefined,
|
||
port: url.port ? parseInt(url.port, 10) : undefined,
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
let username: string | undefined;
|
||
let hostname: string;
|
||
let port: number | undefined;
|
||
let rest = trimmed;
|
||
|
||
const atIndex = rest.indexOf("@");
|
||
if (atIndex !== -1) {
|
||
username = rest.slice(0, atIndex);
|
||
rest = rest.slice(atIndex + 1);
|
||
}
|
||
|
||
if (rest.startsWith("[")) {
|
||
const bracketEnd = rest.indexOf("]");
|
||
if (bracketEnd !== -1) {
|
||
hostname = rest.slice(1, bracketEnd);
|
||
const portPart = rest.slice(bracketEnd + 1);
|
||
if (portPart.startsWith(":")) {
|
||
const p = parseInt(portPart.slice(1), 10);
|
||
if (Number.isFinite(p) && p >= 1 && p <= 65535) port = p;
|
||
}
|
||
} else {
|
||
hostname = rest;
|
||
}
|
||
} else {
|
||
const colonIndex = rest.lastIndexOf(":");
|
||
if (colonIndex !== -1) {
|
||
const portStr = rest.slice(colonIndex + 1);
|
||
const p = parseInt(portStr, 10);
|
||
if (Number.isFinite(p) && p >= 1 && p <= 65535) {
|
||
port = p;
|
||
hostname = rest.slice(0, colonIndex);
|
||
} else {
|
||
hostname = rest;
|
||
}
|
||
} else {
|
||
hostname = rest;
|
||
}
|
||
}
|
||
|
||
if (!hostname) return null;
|
||
return { hostname, username, port };
|
||
};
|
||
const parseProxyJump = (value: string): ParsedJumpHost[] => {
|
||
if (!value || value.toLowerCase() === "none") return [];
|
||
return value
|
||
.split(",")
|
||
.map((s) => s.trim())
|
||
.filter(Boolean)
|
||
.map(parseJumpHostSpec)
|
||
.filter((h): h is ParsedJumpHost => h !== null);
|
||
};
|
||
|
||
export type VaultImportFormat =
|
||
| "putty"
|
||
| "mobaxterm"
|
||
| "csv"
|
||
| "securecrt"
|
||
| "ssh_config";
|
||
|
||
export const VAULT_IMPORT_FORMATS: VaultImportFormat[] = [
|
||
"csv",
|
||
"putty",
|
||
"mobaxterm",
|
||
"securecrt",
|
||
"ssh_config",
|
||
];
|
||
|
||
type VaultImportIssueLevel = "warning" | "error";
|
||
|
||
export interface VaultImportIssue {
|
||
level: VaultImportIssueLevel;
|
||
message: string;
|
||
}
|
||
|
||
export interface VaultImportStats {
|
||
parsed: number;
|
||
imported: number;
|
||
skipped: number;
|
||
duplicates: number;
|
||
}
|
||
|
||
export interface VaultImportResult {
|
||
hosts: Host[];
|
||
groups: string[];
|
||
issues: VaultImportIssue[];
|
||
stats: VaultImportStats;
|
||
keyPassphrases?: VaultHostKeyPassphrase[];
|
||
keyPassphraseCandidates?: VaultHostKeyPassphrase[];
|
||
}
|
||
|
||
export type VaultImportDestination =
|
||
| { mode: "preserve" }
|
||
| { mode: "group"; group: string };
|
||
|
||
export type ApplyVaultImportDestinationOptions = {
|
||
/**
|
||
* When false, only rewrite groups. SecureCRT keeps distinct session files that
|
||
* share an endpoint even after an import-location override.
|
||
* @default true
|
||
*/
|
||
collapseDuplicateEndpoints?: boolean;
|
||
/**
|
||
* Return false to keep a host even when another shares its merge key after the
|
||
* destination rewrite (plugin hosts that differ by configuration/credentials).
|
||
*/
|
||
isCollapsible?: (host: Host) => boolean;
|
||
};
|
||
|
||
export function mergeVaultImportIssues(
|
||
...groups: ReadonlyArray<ReadonlyArray<VaultImportIssue>>
|
||
): VaultImportIssue[] {
|
||
const seen = new Set<string>();
|
||
return groups.flatMap((issues) => issues.filter((issue) => {
|
||
const key = `${issue.level}\u0000${issue.message}`;
|
||
if (seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
}));
|
||
}
|
||
|
||
const DEFAULT_SSH_PORT = 22;
|
||
|
||
const normalizeGroupPath = (raw: string | undefined): string | undefined => {
|
||
const trimmed = raw?.trim();
|
||
if (!trimmed) return undefined;
|
||
const normalized = trimmed.replace(/\\/g, "/");
|
||
const parts = normalized.split("/").map((p) => p.trim()).filter(Boolean);
|
||
if (parts.length === 0) return undefined;
|
||
return parts.join("/");
|
||
};
|
||
|
||
export function applyVaultImportDestination(
|
||
result: VaultImportResult,
|
||
destination: VaultImportDestination,
|
||
options?: ApplyVaultImportDestinationOptions,
|
||
): VaultImportResult {
|
||
if (destination.mode === "preserve") return result;
|
||
const group = normalizeGroupPath(destination.group);
|
||
if (!group) return result;
|
||
|
||
const rewrittenHosts = result.hosts.map((host) => ({ ...host, group }));
|
||
// SecureCRT (and similar) keep same-endpoint sessions as distinct imports.
|
||
if (options?.collapseDuplicateEndpoints === false) {
|
||
return {
|
||
...result,
|
||
hosts: rewrittenHosts,
|
||
groups: [group],
|
||
};
|
||
}
|
||
|
||
// Destination rewriting can collapse previously distinct group-scoped sessions
|
||
// onto one merge key; re-dedupe so identical endpoints are not imported twice.
|
||
// Plugin hosts can opt out via isCollapsible so configuration/credential
|
||
// differences survive a shared destination group.
|
||
const { hosts, duplicates } = dedupeHosts(rewrittenHosts, {
|
||
isCollapsible: options?.isCollapsible,
|
||
});
|
||
const retainedIds = new Set(hosts.map((host) => host.id));
|
||
const retainedByMergeKey = new Map(
|
||
hosts
|
||
.filter((host) => options?.isCollapsible?.(host) !== false)
|
||
.map((host) => [hostKey(host), host]),
|
||
);
|
||
const originalById = new Map(result.hosts.map((host) => [host.id, host]));
|
||
|
||
const remapHostId = (hostId: string): string | undefined => {
|
||
if (retainedIds.has(hostId)) return hostId;
|
||
const original = originalById.get(hostId);
|
||
if (!original) return undefined;
|
||
if (options?.isCollapsible?.({ ...original, group }) === false) return undefined;
|
||
return retainedByMergeKey.get(hostKey({ ...original, group }))?.id;
|
||
};
|
||
|
||
const selectedKeyByHostId = new Map(
|
||
hosts.map((host) => {
|
||
const selected = host.identityFilePaths?.find((path) => path.trim())?.trim();
|
||
return [host.id, selected ? normalizeKeyPathKey(selected) : undefined] as const;
|
||
}),
|
||
);
|
||
|
||
const remapKeyPassphrases = (
|
||
entries: VaultHostKeyPassphrase[] | undefined,
|
||
remapOptions?: { matchSelectedKey?: boolean },
|
||
): VaultHostKeyPassphrase[] | undefined => {
|
||
if (!entries) return entries;
|
||
const remapped: VaultHostKeyPassphrase[] = [];
|
||
const seen = new Set<string>();
|
||
for (const entry of entries) {
|
||
const nextHostId = remapHostId(entry.hostId);
|
||
if (!nextHostId) continue;
|
||
// Match CSV same-group dedupe: never attach a passphrase for a key the
|
||
// retained host did not keep as its selected identity file.
|
||
if (remapOptions?.matchSelectedKey) {
|
||
const selectedKey = selectedKeyByHostId.get(nextHostId);
|
||
if (!selectedKey || normalizeKeyPathKey(entry.keyPath) !== selectedKey) continue;
|
||
}
|
||
const dedupeKey = `${nextHostId}\u0000${normalizeKeyPathKey(entry.keyPath)}\u0000${entry.passphrase}`;
|
||
if (seen.has(dedupeKey)) continue;
|
||
seen.add(dedupeKey);
|
||
remapped.push(nextHostId === entry.hostId ? entry : { ...entry, hostId: nextHostId });
|
||
}
|
||
return remapped;
|
||
};
|
||
|
||
return {
|
||
...result,
|
||
hosts,
|
||
groups: [group],
|
||
keyPassphrases: remapKeyPassphrases(result.keyPassphrases, { matchSelectedKey: true }),
|
||
// Keep remapped candidates for alias conflict checks (same as CSV same-group
|
||
// dedupe); the import handler still gates saves on the retained key path.
|
||
keyPassphraseCandidates: remapKeyPassphrases(result.keyPassphraseCandidates),
|
||
stats: {
|
||
...result.stats,
|
||
imported: hosts.length,
|
||
duplicates: result.stats.duplicates + duplicates,
|
||
},
|
||
};
|
||
}
|
||
|
||
const normalizeProtocol = (
|
||
raw: string | undefined,
|
||
): Exclude<HostProtocol, "mosh" | "et"> | undefined => {
|
||
const s = raw?.trim().toLowerCase();
|
||
if (!s) return undefined;
|
||
if (s === "ssh" || s === "ssh2" || s === "ssh-2") return "ssh";
|
||
if (s === "telnet") return "telnet";
|
||
if (s === "local") return "local";
|
||
return undefined;
|
||
};
|
||
|
||
const parsePort = (raw: string | undefined): number | undefined => {
|
||
const s = raw?.trim();
|
||
if (!s) return undefined;
|
||
const n = parseInt(s, 10);
|
||
if (!Number.isFinite(n) || n < 1 || n > 65535) return undefined;
|
||
return n;
|
||
};
|
||
|
||
const splitTags = (raw: string | undefined): string[] => {
|
||
const s = raw?.trim();
|
||
if (!s) return [];
|
||
return s
|
||
.split(/[,;,]/g)
|
||
.map((t) => t.trim())
|
||
.filter(Boolean);
|
||
};
|
||
|
||
const hostKey = buildVaultHostMergeKey;
|
||
|
||
const normalizeKeyPathKey = (keyPath: string): string => {
|
||
const isWindowsPath = /^[A-Za-z]:[\\/]/u.test(keyPath) || /^[\\/]{2}/u.test(keyPath);
|
||
return isWindowsPath ? keyPath.replace(/\\/g, "/").toLowerCase() : keyPath;
|
||
};
|
||
|
||
const createHost = (input: {
|
||
label?: string;
|
||
hostname: string;
|
||
username?: string;
|
||
password?: string;
|
||
keyPath?: string;
|
||
port?: number;
|
||
protocol?: VaultHostDraftProtocol;
|
||
group?: string;
|
||
tags?: string[];
|
||
notes?: string;
|
||
}): Host => {
|
||
const built = buildVaultHostFromDraft(input);
|
||
if (!built.ok) {
|
||
throw new Error(built.error);
|
||
}
|
||
return built.host;
|
||
};
|
||
|
||
const dedupeHosts = (
|
||
hosts: Host[],
|
||
options?: { isCollapsible?: (host: Host) => boolean },
|
||
): { hosts: Host[]; duplicates: number } => {
|
||
const seen = new Map<string, Host>();
|
||
const retained: Host[] = [];
|
||
let duplicates = 0;
|
||
|
||
for (const host of hosts) {
|
||
if (options?.isCollapsible?.(host) === false) {
|
||
retained.push(host);
|
||
continue;
|
||
}
|
||
const key = hostKey(host);
|
||
const existing = seen.get(key);
|
||
if (!existing) {
|
||
seen.set(key, host);
|
||
retained.push(host);
|
||
continue;
|
||
}
|
||
duplicates++;
|
||
const mergedTags = Array.from(new Set([...(existing.tags ?? []), ...(host.tags ?? [])]));
|
||
existing.tags = mergedTags;
|
||
if (!existing.password && host.password) existing.password = host.password;
|
||
if (!existing.identityFilePaths?.some((path) => path.trim()) && host.identityFilePaths?.length) {
|
||
existing.identityFilePaths = host.identityFilePaths;
|
||
existing.authMethod = host.authMethod;
|
||
existing.authPolicyVersion = host.authPolicyVersion;
|
||
existing.useSshAgent = host.useSshAgent;
|
||
}
|
||
// Only graft referenced credentials onto a retained host that has no auth
|
||
// source yet. Otherwise identity auth can override an existing key path.
|
||
const retainedHasCredentialSource = Boolean(
|
||
existing.identityId
|
||
|| existing.telnetIdentityId
|
||
|| existing.identityFileId
|
||
|| existing.identityFilePaths?.some((path) => path.trim())
|
||
|| (typeof existing.password === "string" && existing.password.length > 0)
|
||
);
|
||
if (!retainedHasCredentialSource) {
|
||
if (host.identityId) existing.identityId = host.identityId;
|
||
if (host.telnetIdentityId) existing.telnetIdentityId = host.telnetIdentityId;
|
||
if (host.identityFileId) existing.identityFileId = host.identityFileId;
|
||
}
|
||
if (existing.group == null && host.group != null) existing.group = host.group;
|
||
if (existing.label === existing.hostname && host.label && host.label !== host.hostname) {
|
||
existing.label = host.label;
|
||
}
|
||
}
|
||
|
||
return { hosts: retained, duplicates };
|
||
};
|
||
|
||
const uniq = (items: string[]) => Array.from(new Set(items.filter(Boolean)));
|
||
|
||
const looksLikeHostnameToken = (token: string): boolean => {
|
||
const qc = parseQuickConnectInput(token.trim());
|
||
return qc !== null;
|
||
};
|
||
|
||
const parseTarget = (
|
||
raw: string,
|
||
): { hostname: string; username?: string; port?: number; protocol?: Exclude<HostProtocol, "mosh" | "et"> } | null => {
|
||
const trimmed = raw.trim();
|
||
if (!trimmed) return null;
|
||
|
||
// URL form: ssh://user@host:22
|
||
if (trimmed.includes("://")) {
|
||
try {
|
||
const url = new URL(trimmed);
|
||
const protocol = normalizeProtocol(url.protocol.replace(/:$/, ""));
|
||
const hostname = url.hostname;
|
||
const port = url.port ? parsePort(url.port) : undefined;
|
||
const username = url.username || undefined;
|
||
if (!hostname) return null;
|
||
return { hostname, username, port, protocol };
|
||
} catch {
|
||
// fall through
|
||
}
|
||
}
|
||
|
||
// host:proto form (seen in some CSV exports)
|
||
const protoSuffixMatch = trimmed.match(/^(.*?)(?::|\s+)(ssh|ssh2|telnet|local)$/i);
|
||
if (protoSuffixMatch) {
|
||
const left = protoSuffixMatch[1].trim();
|
||
const protocol = normalizeProtocol(protoSuffixMatch[2]);
|
||
const base = parseQuickConnectInput(left);
|
||
if (base) return { hostname: base.hostname, username: base.username, port: base.port, protocol };
|
||
}
|
||
|
||
const qc = parseQuickConnectInput(trimmed);
|
||
if (qc) return { hostname: qc.hostname, username: qc.username, port: qc.port };
|
||
return null;
|
||
};
|
||
|
||
|
||
const importFromCsv = (text: string): VaultImportResult => {
|
||
const issues: VaultImportIssue[] = [];
|
||
const rows = parseCsv(text).filter((r) => r.some((c) => c.trim() !== ""));
|
||
if (rows.length === 0) {
|
||
return {
|
||
hosts: [],
|
||
groups: [],
|
||
issues: [{ level: "error", message: "CSV is empty." }],
|
||
stats: { parsed: 0, imported: 0, skipped: 0, duplicates: 0 },
|
||
};
|
||
}
|
||
|
||
const header = rows[0];
|
||
const dataRows = rows.slice(1);
|
||
|
||
const groupsIdx = findHeaderIndex(header, ["groups", "group", "folder", "path"]);
|
||
const labelIdx = findHeaderIndex(header, ["label", "name"]);
|
||
const tagsIdx = findHeaderIndex(header, ["tags", "tag"]);
|
||
const notesIdx = findHeaderIndex(header, ["notes", "note", "remark", "description", "memo"]);
|
||
const hostnameIdx = findHeaderIndex(header, ["hostname", "host", "server"]);
|
||
const protocolIdx = findHeaderIndex(header, ["protocol", "proto", "scheme"]);
|
||
const portIdx = findHeaderIndex(header, ["port"]);
|
||
const usernameIdx = findHeaderIndex(header, ["username", "user", "login"]);
|
||
const keyPathIdx = findExactHeaderIndex(header, ["keypath", "key path", "identityfile", "identity file"]);
|
||
const explicitPassphraseIdx = findExactHeaderIndex(header, ["passphrase", "keypassphrase", "key passphrase"]);
|
||
const passphraseIdx = keyPathIdx >= 0 ? explicitPassphraseIdx : -1;
|
||
const exactPasswordIdx = findExactHeaderIndex(header, ["password", "pass", "passwd"]);
|
||
const fuzzyNamedPasswordIdx = findHeaderIndex(header, ["password", "passwd"]);
|
||
const fuzzyPassIdx = header.findIndex((value) => {
|
||
const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]/gu, "");
|
||
if (!normalized.startsWith("pass")) return false;
|
||
if (!normalized.startsWith("passphrase")) return true;
|
||
const suffix = normalized.slice("passphrase".length);
|
||
return keyPathIdx < 0 && ["", "optional", "value"].includes(suffix);
|
||
});
|
||
const fuzzyPasswordIdx = fuzzyNamedPasswordIdx >= 0 ? fuzzyNamedPasswordIdx : fuzzyPassIdx;
|
||
const passwordIdx = exactPasswordIdx >= 0
|
||
? exactPasswordIdx
|
||
: keyPathIdx < 0 && explicitPassphraseIdx >= 0
|
||
? explicitPassphraseIdx
|
||
: fuzzyPasswordIdx;
|
||
|
||
if (hostnameIdx === -1) {
|
||
return {
|
||
hosts: [],
|
||
groups: [],
|
||
issues: [
|
||
{
|
||
level: "error",
|
||
message:
|
||
"CSV header must include a Hostname column (e.g. Hostname, Host).",
|
||
},
|
||
],
|
||
stats: { parsed: 0, imported: 0, skipped: 0, duplicates: 0 },
|
||
};
|
||
}
|
||
|
||
const parsedHosts: Host[] = [];
|
||
const keyPassphraseCandidates: Array<{
|
||
hostKey: string;
|
||
keyPathKey: string;
|
||
keyPath: string;
|
||
passphrase: string;
|
||
}> = [];
|
||
const keyPassphrasesByPath = new Map<string, {
|
||
keyPath: string;
|
||
passphrase?: string;
|
||
conflict: boolean;
|
||
}>();
|
||
let parsed = 0;
|
||
let skipped = 0;
|
||
|
||
for (let i = 0; i < dataRows.length; i++) {
|
||
const row = dataRows[i];
|
||
const hostnameRaw = (row[hostnameIdx] ?? "").trim();
|
||
if (!hostnameRaw) continue;
|
||
parsed++;
|
||
|
||
const target = parseTarget(hostnameRaw);
|
||
if (!target) {
|
||
skipped++;
|
||
issues.push({
|
||
level: "warning",
|
||
message: `CSV row ${i + 2}: invalid hostname value "${hostnameRaw}".`,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
const group = groupsIdx >= 0 ? normalizeGroupPath(row[groupsIdx]) : undefined;
|
||
const label = labelIdx >= 0 ? row[labelIdx] : undefined;
|
||
const tags = tagsIdx >= 0 ? splitTags(row[tagsIdx]) : [];
|
||
const notesRaw = notesIdx >= 0 ? row[notesIdx] : undefined;
|
||
const notes = notesRaw?.trim() || undefined;
|
||
const protocol =
|
||
normalizeProtocol(protocolIdx >= 0 ? row[protocolIdx] : undefined) ??
|
||
target.protocol ??
|
||
"ssh";
|
||
const port = parsePort(portIdx >= 0 ? row[portIdx] : undefined) ?? target.port;
|
||
const username = (usernameIdx >= 0 ? row[usernameIdx] : undefined)?.trim() || target.username;
|
||
const password = (passwordIdx >= 0 ? row[passwordIdx] : undefined) || undefined;
|
||
const keyPathRaw = (keyPathIdx >= 0 ? row[keyPathIdx] : undefined)?.trim();
|
||
const keyPath = keyPathRaw ? decodeCsvKeyPath(keyPathRaw) : undefined;
|
||
const passphraseRaw = (passphraseIdx >= 0 ? row[passphraseIdx] : undefined) || undefined;
|
||
const decodedPassphrase = passphraseRaw ? decodeCsvPassphrase(passphraseRaw) : undefined;
|
||
const passphrase = decodedPassphrase && !isEncryptedCredentialPlaceholder(decodedPassphrase)
|
||
? decodedPassphrase
|
||
: undefined;
|
||
|
||
if (decodedPassphrase && isEncryptedCredentialPlaceholder(decodedPassphrase)) {
|
||
issues.push({
|
||
level: "warning",
|
||
message: `CSV row ${i + 2}: Passphrase was ignored because encrypted credential values cannot be imported.`,
|
||
});
|
||
}
|
||
|
||
if (passphrase && !keyPath) {
|
||
issues.push({
|
||
level: "warning",
|
||
message: `CSV row ${i + 2}: Passphrase was ignored because KeyPath is empty.`,
|
||
});
|
||
}
|
||
|
||
const host = createHost({
|
||
label,
|
||
hostname: target.hostname,
|
||
username,
|
||
password,
|
||
keyPath,
|
||
port,
|
||
protocol,
|
||
group,
|
||
tags,
|
||
notes,
|
||
});
|
||
parsedHosts.push(host);
|
||
if (keyPath && passphrase) {
|
||
const keyPathKey = normalizeKeyPathKey(keyPath);
|
||
const stored = keyPassphrasesByPath.get(keyPathKey);
|
||
if (!stored) {
|
||
keyPassphrasesByPath.set(keyPathKey, { keyPath, passphrase, conflict: false });
|
||
} else if (stored.passphrase !== passphrase && !stored.conflict) {
|
||
stored.conflict = true;
|
||
issues.push({
|
||
level: "warning",
|
||
message: `CSV contains conflicting passphrases for KeyPath "${keyPath}"; no passphrase was saved for that path.`,
|
||
});
|
||
}
|
||
keyPassphraseCandidates.push({
|
||
hostKey: buildVaultHostMergeKey(host),
|
||
keyPathKey,
|
||
keyPath,
|
||
passphrase,
|
||
});
|
||
}
|
||
}
|
||
|
||
const { hosts, duplicates } = dedupeHosts(parsedHosts);
|
||
const keyPassphrases = hosts.flatMap((host) => {
|
||
const selectedKeyPath = host.identityFilePaths?.find((path) => path.trim())?.trim();
|
||
if (!selectedKeyPath) return [];
|
||
const selectedKeyPathKey = normalizeKeyPathKey(selectedKeyPath);
|
||
const candidate = keyPassphraseCandidates.find((entry) => (
|
||
entry.hostKey === buildVaultHostMergeKey(host)
|
||
&& entry.keyPathKey === selectedKeyPathKey
|
||
));
|
||
const entry = candidate ? keyPassphrasesByPath.get(candidate.keyPathKey) : undefined;
|
||
return entry?.passphrase && !entry.conflict
|
||
? [{ hostId: host.id, keyPath: entry.keyPath, passphrase: entry.passphrase }]
|
||
: [];
|
||
});
|
||
const allKeyPassphraseCandidates = hosts.flatMap((host) => {
|
||
return keyPassphraseCandidates
|
||
.filter((entry) => entry.hostKey === buildVaultHostMergeKey(host))
|
||
.map((entry) => ({
|
||
hostId: host.id,
|
||
keyPath: entry.keyPath,
|
||
passphrase: entry.passphrase,
|
||
}));
|
||
});
|
||
const groups = uniq(hosts.map((h) => h.group).filter(Boolean) as string[]);
|
||
return {
|
||
hosts,
|
||
groups,
|
||
issues,
|
||
keyPassphrases,
|
||
keyPassphraseCandidates: allKeyPassphraseCandidates,
|
||
stats: {
|
||
parsed,
|
||
imported: hosts.length,
|
||
skipped,
|
||
duplicates,
|
||
},
|
||
};
|
||
};
|
||
|
||
const decodeRegString = (raw: string): string => {
|
||
const trimmed = raw.trim();
|
||
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) return trimmed;
|
||
const inner = trimmed.slice(1, -1);
|
||
return inner.replace(/\\\\/g, "\\").replace(/\\"/g, '"');
|
||
};
|
||
|
||
const parseDword = (raw: string): number | undefined => {
|
||
const trimmed = raw.trim();
|
||
const m = trimmed.match(/^dword:([0-9a-fA-F]{8})$/);
|
||
if (!m) return undefined;
|
||
const n = parseInt(m[1], 16);
|
||
if (!Number.isFinite(n) || n < 1 || n > 65535) return undefined;
|
||
return n;
|
||
};
|
||
|
||
const decodePuttySessionName = (raw: string): string => {
|
||
try {
|
||
return decodeURIComponent(raw);
|
||
} catch {
|
||
return raw;
|
||
}
|
||
};
|
||
|
||
const importFromPuttyReg = (text: string): VaultImportResult => {
|
||
const issues: VaultImportIssue[] = [];
|
||
const lines = text.split(/\r?\n/);
|
||
|
||
type Session = {
|
||
name: string;
|
||
hostname?: string;
|
||
username?: string;
|
||
port?: number;
|
||
protocol?: Exclude<HostProtocol, "mosh" | "et">;
|
||
};
|
||
|
||
const sessions: Session[] = [];
|
||
let current: Session | null = null;
|
||
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed) continue;
|
||
|
||
const sectionMatch = trimmed.match(
|
||
/^\[HKEY_(?:CURRENT_USER|LOCAL_MACHINE)\\Software\\SimonTatham\\PuTTY\\Sessions\\(.+)\]$/i,
|
||
);
|
||
if (sectionMatch) {
|
||
if (current) sessions.push(current);
|
||
current = { name: decodePuttySessionName(sectionMatch[1]) };
|
||
continue;
|
||
}
|
||
|
||
if (!current) continue;
|
||
|
||
const kvMatch = trimmed.match(/^"([^"]+)"=(.+)$/);
|
||
if (!kvMatch) continue;
|
||
const key = kvMatch[1];
|
||
const value = kvMatch[2];
|
||
|
||
if (key === "HostName") current.hostname = decodeRegString(value);
|
||
else if (key === "UserName") current.username = decodeRegString(value);
|
||
else if (key === "PortNumber") current.port = parseDword(value);
|
||
else if (key === "Protocol") current.protocol = normalizeProtocol(decodeRegString(value));
|
||
}
|
||
if (current) sessions.push(current);
|
||
|
||
const parsedHosts: Host[] = [];
|
||
let parsed = 0;
|
||
let skipped = 0;
|
||
|
||
for (const s of sessions) {
|
||
if (!s.hostname) continue;
|
||
parsed++;
|
||
const protocol = s.protocol ?? "ssh";
|
||
if (protocol !== "ssh" && protocol !== "telnet") {
|
||
skipped++;
|
||
issues.push({
|
||
level: "warning",
|
||
message: `PuTTY session "${s.name}": unsupported protocol.`,
|
||
});
|
||
continue;
|
||
}
|
||
parsedHosts.push(
|
||
createHost({
|
||
label: s.name,
|
||
hostname: s.hostname,
|
||
username: s.username,
|
||
port: s.port ?? (protocol === "ssh" ? DEFAULT_SSH_PORT : 23),
|
||
protocol,
|
||
}),
|
||
);
|
||
}
|
||
|
||
const { hosts, duplicates } = dedupeHosts(parsedHosts);
|
||
return {
|
||
hosts,
|
||
groups: [],
|
||
issues,
|
||
stats: { parsed, imported: hosts.length, skipped, duplicates },
|
||
};
|
||
};
|
||
|
||
const importFromSshConfig = (text: string): VaultImportResult => {
|
||
const issues: VaultImportIssue[] = [];
|
||
const lines = text.split(/\r?\n/);
|
||
|
||
type Block = {
|
||
patterns: string[];
|
||
hostname?: string;
|
||
username?: string;
|
||
port?: number;
|
||
proxyJump?: string;
|
||
identityFiles?: string[];
|
||
identityAgent?: string;
|
||
identitiesOnly?: boolean;
|
||
addKeysToAgent?: string;
|
||
useKeychain?: boolean;
|
||
forwardX11?: boolean;
|
||
};
|
||
|
||
const blocks: Block[] = [];
|
||
let current: Block | null = null;
|
||
|
||
const flush = () => {
|
||
if (current) blocks.push(current);
|
||
current = null;
|
||
};
|
||
|
||
for (const line of lines) {
|
||
const cleaned = line.replace(/#.*/, "").trim();
|
||
if (!cleaned) continue;
|
||
|
||
const tokens = cleaned.split(/\s+/).filter(Boolean);
|
||
const keyword = tokens[0]?.toLowerCase();
|
||
if (!keyword) continue;
|
||
|
||
if (keyword === "host") {
|
||
flush();
|
||
current = { patterns: tokens.slice(1) };
|
||
continue;
|
||
}
|
||
|
||
if (keyword === "match") {
|
||
flush();
|
||
continue;
|
||
}
|
||
|
||
if (!current) continue;
|
||
|
||
const value = tokens.slice(1).join(" ");
|
||
if (!value) continue;
|
||
|
||
if (keyword === "hostname") current.hostname = value;
|
||
else if (keyword === "user") current.username = value;
|
||
else if (keyword === "port") current.port = parsePort(value);
|
||
else if (keyword === "proxyjump") current.proxyJump = value;
|
||
else if (keyword === "forwardx11") current.forwardX11 = value.toLowerCase() === "yes";
|
||
else if (keyword === "identityagent") current.identityAgent = value.replace(/^["']|["']$/g, "");
|
||
else if (keyword === "identitiesonly") current.identitiesOnly = value.toLowerCase() === "yes";
|
||
else if (keyword === "addkeystoagent") current.addKeysToAgent = value.toLowerCase();
|
||
else if (keyword === "usekeychain") current.useKeychain = value.toLowerCase() === "yes";
|
||
else if (keyword === "identityfile") {
|
||
if (!current.identityFiles) current.identityFiles = [];
|
||
// Remove surrounding quotes (ssh_config allows quoted paths with spaces)
|
||
const unquoted = value.replace(/^["']|["']$/g, "");
|
||
current.identityFiles.push(unquoted);
|
||
}
|
||
}
|
||
|
||
flush();
|
||
|
||
const parsedHosts: Host[] = [];
|
||
// Use hostname+port as key instead of host.id to survive deduplication
|
||
const hostProxyJumpMap = new Map<string, string>();
|
||
let parsed = 0;
|
||
let skipped = 0;
|
||
|
||
const isWildcardPattern = (p: string) => /[*?]/.test(p) || p === "!" || p.startsWith("!");
|
||
|
||
// Helper to create a stable key for ProxyJump mapping
|
||
const makeHostKey = (hostname: string, port?: number) =>
|
||
`${hostname.toLowerCase()}:${port ?? 22}`;
|
||
|
||
for (const block of blocks) {
|
||
const patterns = block.patterns.filter((p) => p && !isWildcardPattern(p));
|
||
if (patterns.length === 0) continue;
|
||
|
||
for (const pat of patterns) {
|
||
parsed++;
|
||
const hostname = block.hostname ?? pat;
|
||
if (!looksLikeHostnameToken(hostname)) {
|
||
skipped++;
|
||
issues.push({
|
||
level: "warning",
|
||
message: `ssh_config: skipped host "${pat}" (invalid hostname).`,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
const host = createHost({
|
||
label: pat,
|
||
hostname,
|
||
username: block.username,
|
||
port: block.port,
|
||
protocol: "ssh",
|
||
});
|
||
|
||
// Attach IdentityFile paths if present
|
||
if (block.identityFiles && block.identityFiles.length > 0) {
|
||
host.identityFilePaths = [...block.identityFiles];
|
||
}
|
||
if (block.identityAgent !== undefined) {
|
||
host.identityAgent = block.identityAgent;
|
||
}
|
||
if (block.identitiesOnly !== undefined) {
|
||
host.identitiesOnly = block.identitiesOnly;
|
||
}
|
||
if (block.addKeysToAgent !== undefined) {
|
||
host.addKeysToAgent = block.addKeysToAgent;
|
||
}
|
||
if (block.useKeychain !== undefined) {
|
||
host.useKeychain = block.useKeychain;
|
||
}
|
||
const identityAgentEnabled = block.identityAgent !== undefined
|
||
&& block.identityAgent.toLowerCase() !== "none";
|
||
const identityAgentDisabled = block.identityAgent?.toLowerCase() === "none";
|
||
// The #2119 macOS pattern relies on AddKeysToAgent + UseKeychain without
|
||
// declaring IdentityAgent. Treat that pair as an agent-backed login so
|
||
// the bridge can ask Apple's ssh-add to load the configured IdentityFile.
|
||
// AddKeysToAgent alone still keeps direct-key semantics on other setups.
|
||
const macKeychainAgentEnabled = hasMacKeychainAgentDirectives(block);
|
||
if (!identityAgentDisabled && (identityAgentEnabled || macKeychainAgentEnabled)) {
|
||
host.useSshAgent = true;
|
||
} else if (identityAgentDisabled) {
|
||
host.useSshAgent = false;
|
||
}
|
||
if (block.forwardX11 !== undefined) {
|
||
host.x11Forwarding = block.forwardX11;
|
||
}
|
||
|
||
parsedHosts.push(host);
|
||
|
||
// Store ProxyJump using hostname key (survives deduplication)
|
||
if (block.proxyJump && block.proxyJump.toLowerCase() !== "none") {
|
||
const hostKey = makeHostKey(hostname, block.port);
|
||
hostProxyJumpMap.set(hostKey, block.proxyJump);
|
||
}
|
||
}
|
||
}
|
||
|
||
const { hosts: dedupedHosts, duplicates } = dedupeHosts(parsedHosts);
|
||
|
||
const hostnameToId = new Map<string, string>();
|
||
const labelToId = new Map<string, string>();
|
||
for (const host of dedupedHosts) {
|
||
hostnameToId.set(host.hostname.toLowerCase(), host.id);
|
||
labelToId.set(host.label.toLowerCase(), host.id);
|
||
}
|
||
|
||
const resolveJumpHostToId = (jumpHost: ParsedJumpHost): string | null => {
|
||
const hostnameKey = jumpHost.hostname.toLowerCase();
|
||
if (labelToId.has(hostnameKey)) return labelToId.get(hostnameKey)!;
|
||
if (hostnameToId.has(hostnameKey)) return hostnameToId.get(hostnameKey)!;
|
||
return null;
|
||
};
|
||
|
||
// Collect inline hosts separately to avoid modifying array during iteration
|
||
const inlineHosts: Host[] = [];
|
||
|
||
// Process ProxyJump for each host (iterate over a copy to avoid issues)
|
||
const hostsToProcess = [...dedupedHosts];
|
||
for (const host of hostsToProcess) {
|
||
const hostKey = makeHostKey(host.hostname, host.port);
|
||
const proxyJumpValue = hostProxyJumpMap.get(hostKey);
|
||
if (!proxyJumpValue) continue;
|
||
|
||
const jumpHosts = parseProxyJump(proxyJumpValue);
|
||
if (jumpHosts.length === 0) continue;
|
||
|
||
const resolvedIds: string[] = [];
|
||
const unresolvedJumps: string[] = [];
|
||
|
||
for (const jumpHost of jumpHosts) {
|
||
const existingId = resolveJumpHostToId(jumpHost);
|
||
if (existingId) {
|
||
// Avoid duplicate IDs in the chain
|
||
if (!resolvedIds.includes(existingId)) {
|
||
resolvedIds.push(existingId);
|
||
}
|
||
} else {
|
||
// Check if we already created an inline host for this
|
||
const inlineKey = jumpHost.hostname.toLowerCase();
|
||
let inlineId = hostnameToId.get(inlineKey);
|
||
|
||
if (!inlineId) {
|
||
const inlineHost = createHost({
|
||
label: jumpHost.hostname,
|
||
hostname: jumpHost.hostname,
|
||
username: jumpHost.username,
|
||
port: jumpHost.port,
|
||
protocol: "ssh",
|
||
});
|
||
inlineHosts.push(inlineHost);
|
||
hostnameToId.set(inlineHost.hostname.toLowerCase(), inlineHost.id);
|
||
labelToId.set(inlineHost.label.toLowerCase(), inlineHost.id);
|
||
inlineId = inlineHost.id;
|
||
unresolvedJumps.push(jumpHost.hostname);
|
||
}
|
||
|
||
if (!resolvedIds.includes(inlineId)) {
|
||
resolvedIds.push(inlineId);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (resolvedIds.length > 0) {
|
||
// Cycle detection: check if this host appears in its own chain
|
||
if (resolvedIds.includes(host.id)) {
|
||
issues.push({
|
||
level: "warning",
|
||
message: `ssh_config: detected circular reference in ProxyJump for "${host.label}", skipping chain.`,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
const hostChain: HostChainConfig = { hostIds: resolvedIds };
|
||
host.hostChain = hostChain;
|
||
}
|
||
|
||
if (unresolvedJumps.length > 0) {
|
||
issues.push({
|
||
level: "warning",
|
||
message: `ssh_config: created inline jump host(s) for "${host.label}": ${unresolvedJumps.join(", ")}`,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Add inline hosts to the final result
|
||
const allHosts = [...dedupedHosts, ...inlineHosts];
|
||
|
||
// Deep cycle detection: check for indirect cycles (A -> B -> C -> A)
|
||
const detectCycle = (hostId: string, visited: Set<string>): boolean => {
|
||
if (visited.has(hostId)) return true;
|
||
visited.add(hostId);
|
||
const host = allHosts.find(h => h.id === hostId);
|
||
if (host?.hostChain?.hostIds) {
|
||
for (const chainId of host.hostChain.hostIds) {
|
||
if (detectCycle(chainId, visited)) return true;
|
||
}
|
||
}
|
||
visited.delete(hostId);
|
||
return false;
|
||
};
|
||
|
||
// Remove chains that form cycles
|
||
for (const host of allHosts) {
|
||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||
if (detectCycle(host.id, new Set())) {
|
||
issues.push({
|
||
level: "warning",
|
||
message: `ssh_config: detected circular dependency in jump chain for "${host.label}", removing chain.`,
|
||
});
|
||
delete host.hostChain;
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
hosts: allHosts,
|
||
groups: [],
|
||
issues,
|
||
stats: { parsed, imported: allHosts.length, skipped, duplicates },
|
||
};
|
||
};
|
||
|
||
const importFromSecureCrt = (text: string, fileName?: string): VaultImportResult => {
|
||
const issues: VaultImportIssue[] = [];
|
||
const lines = text.split(/\r?\n/);
|
||
|
||
type Session = {
|
||
label?: string;
|
||
hostname?: string;
|
||
username?: string;
|
||
port?: number;
|
||
ssh1Port?: number;
|
||
ssh2Port?: number;
|
||
sshVersion?: "ssh1" | "ssh2";
|
||
protocol?: Exclude<HostProtocol, "mosh" | "et">;
|
||
};
|
||
|
||
const sessions: Session[] = [];
|
||
let current: Session = {};
|
||
|
||
const flush = () => {
|
||
if (current.hostname) sessions.push(current);
|
||
current = {};
|
||
};
|
||
|
||
const parseSecureCrtPort = (raw: string): number | undefined => {
|
||
const trimmed = raw.trim().replace(/^"+|"+$/g, "");
|
||
if (!trimmed) return undefined;
|
||
if (/^[0-9a-fA-F]{8}$/.test(trimmed)) {
|
||
const n = parseInt(trimmed, 16);
|
||
if (Number.isFinite(n) && n >= 1 && n <= 65535) return n;
|
||
}
|
||
return parsePort(trimmed);
|
||
};
|
||
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed) continue;
|
||
|
||
const kv = trimmed.match(/^[SDB]:"([^"]+)"=(.*)$/);
|
||
if (!kv) continue;
|
||
|
||
const key = kv[1];
|
||
const rawValue = kv[2].trim();
|
||
const value = rawValue.replace(/^"+|"+$/g, "");
|
||
|
||
if (key === "Hostname") {
|
||
if (current.hostname) flush();
|
||
current.hostname = value;
|
||
} else if (key === "Username") {
|
||
current.username = value;
|
||
} else if (key === "Port") {
|
||
current.port = parseSecureCrtPort(value);
|
||
} else if (key === "[SSH2] Port") {
|
||
current.ssh2Port = parseSecureCrtPort(value);
|
||
} else if (key === "[SSH1] Port") {
|
||
current.ssh1Port = parseSecureCrtPort(value);
|
||
} else if (key === "Protocol Name") {
|
||
const p = normalizeProtocol(value);
|
||
const normalizedName = value.trim().toLowerCase();
|
||
current.sshVersion = normalizedName === "ssh1"
|
||
? "ssh1"
|
||
: normalizedName === "ssh2" || normalizedName === "ssh-2"
|
||
? "ssh2"
|
||
: undefined;
|
||
current.protocol = p ?? (current.sshVersion ? "ssh" : undefined);
|
||
} else if (key === "Session Name") {
|
||
current.label = value;
|
||
}
|
||
}
|
||
flush();
|
||
|
||
const parsedHosts: Host[] = [];
|
||
let parsed = 0;
|
||
let skipped = 0;
|
||
|
||
const fallbackLabel =
|
||
fileName?.replace(/\.[^.]+$/, "") || "SecureCRT Session";
|
||
|
||
for (let i = 0; i < sessions.length; i++) {
|
||
const s = sessions[i];
|
||
if (!s.hostname) continue;
|
||
parsed++;
|
||
const protocol = s.protocol ?? "ssh";
|
||
if (protocol !== "ssh" && protocol !== "telnet") {
|
||
skipped++;
|
||
issues.push({
|
||
level: "warning",
|
||
message: `SecureCRT session: unsupported protocol.`,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
const label = s.label || (sessions.length > 1 ? `${fallbackLabel} ${i + 1}` : fallbackLabel);
|
||
const protocolSpecificPort = protocol === "ssh"
|
||
? s.sshVersion === "ssh1"
|
||
? s.ssh1Port
|
||
: s.sshVersion === "ssh2"
|
||
? s.ssh2Port
|
||
: s.ssh2Port ?? s.ssh1Port
|
||
: undefined;
|
||
parsedHosts.push(
|
||
createHost({
|
||
label,
|
||
hostname: s.hostname,
|
||
username: s.username,
|
||
port: protocolSpecificPort ?? s.port ?? (protocol === "ssh" ? DEFAULT_SSH_PORT : 23),
|
||
protocol,
|
||
}),
|
||
);
|
||
}
|
||
|
||
const { hosts, duplicates } = dedupeHosts(parsedHosts);
|
||
return {
|
||
hosts,
|
||
groups: [],
|
||
issues,
|
||
stats: { parsed, imported: hosts.length, skipped, duplicates },
|
||
};
|
||
};
|
||
|
||
const importFromMobaXterm = (
|
||
text: string,
|
||
options?: { masterPassword?: string },
|
||
): VaultImportResult => {
|
||
const issues: VaultImportIssue[] = [];
|
||
const lines = text.split(/\r?\n/);
|
||
|
||
type Entry = { section: string; key: string; value: string };
|
||
const entries: Entry[] = [];
|
||
const sectionGroups = new Map<string, string | undefined>();
|
||
const passwordEntries = new Map<string, string>();
|
||
const credentialEntries = new Map<string, { username: string; ciphertext: string }>();
|
||
const misc = new Map<string, string>();
|
||
let hasSesspass = false;
|
||
|
||
let section = "";
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed) continue;
|
||
if (trimmed.startsWith(";") || trimmed.startsWith("#")) continue;
|
||
|
||
const mSection = trimmed.match(/^\[(.+)\]$/);
|
||
if (mSection) {
|
||
section = mSection[1];
|
||
continue;
|
||
}
|
||
|
||
const mKv = trimmed.match(/^([^=]+)=(.*)$/);
|
||
if (!mKv) continue;
|
||
const key = mKv[1].trim();
|
||
const value = mKv[2].trim();
|
||
const sectionName = section.trim();
|
||
const isBookmarkSection = /^bookmarks(?:_\d+)?$/i.test(sectionName);
|
||
|
||
if (isBookmarkSection && key.toLowerCase() === "subrep") {
|
||
sectionGroups.set(section, normalizeGroupPath(value));
|
||
continue;
|
||
}
|
||
if (isBookmarkSection && key.toLowerCase() === "imgnum") continue;
|
||
if (/^passwords$/i.test(sectionName) && key && value) {
|
||
passwordEntries.set(key, value);
|
||
continue;
|
||
}
|
||
if (/^credentials$/i.test(sectionName) && key && value) {
|
||
const colon = value.indexOf(":");
|
||
if (colon > 0) {
|
||
credentialEntries.set(key, {
|
||
username: value.slice(0, colon),
|
||
ciphertext: value.slice(colon + 1),
|
||
});
|
||
}
|
||
continue;
|
||
}
|
||
if (/^misc$/i.test(sectionName)) {
|
||
misc.set(key.toLowerCase(), value);
|
||
continue;
|
||
}
|
||
if (/^sesspass$/i.test(sectionName)) {
|
||
hasSesspass = true;
|
||
continue;
|
||
}
|
||
|
||
entries.push({ section, key, value });
|
||
}
|
||
|
||
const candidateEntries = entries.filter((e) =>
|
||
/^(?:sessions|bookmarks(?:_\d+)?|bookmarks2|bookmark)$/i.test(e.section.trim()),
|
||
);
|
||
|
||
const parsedHosts: Host[] = [];
|
||
let parsed = 0;
|
||
let skipped = 0;
|
||
|
||
for (const e of candidateEntries) {
|
||
const rawKey = e.key;
|
||
const rawValue = e.value;
|
||
if (!rawKey || !rawValue) continue;
|
||
|
||
parsed++;
|
||
|
||
const isBookmarkSection = /^bookmarks(?:_\d+)?$/i.test(e.section.trim());
|
||
const hasBookmarkGroup = sectionGroups.has(e.section);
|
||
const keyParts = rawKey.replace(/\\/g, "/").split("/").filter(Boolean);
|
||
const label = isBookmarkSection && hasBookmarkGroup
|
||
? rawKey
|
||
: keyParts[keyParts.length - 1] || rawKey;
|
||
const group = isBookmarkSection && hasBookmarkGroup
|
||
? sectionGroups.get(e.section)
|
||
: keyParts.length > 1
|
||
? keyParts.slice(0, -1).join("/")
|
||
: undefined;
|
||
|
||
let protocol: Exclude<HostProtocol, "mosh" | "et"> | undefined;
|
||
let hostname: string | undefined;
|
||
let username: string | undefined;
|
||
let port: number | undefined;
|
||
|
||
const outerFields = rawValue.split("#");
|
||
const sessionFields = outerFields.length >= 3 ? outerFields[2].split("%") : [];
|
||
const sessionType = sessionFields[0]?.trim();
|
||
const isStandardSession = /^(?:;\s*logout)?\s*#\d+(?:#|$)/i.test(rawValue);
|
||
|
||
if (isStandardSession) {
|
||
if (!/^\d+$/.test(sessionType ?? "")) {
|
||
skipped++;
|
||
issues.push({
|
||
level: "warning",
|
||
message: `MobaXterm entry "${label}": invalid session type.`,
|
||
});
|
||
continue;
|
||
}
|
||
if (sessionType !== "0" && sessionType !== "7") {
|
||
skipped++;
|
||
issues.push({
|
||
level: "warning",
|
||
message: `MobaXterm entry "${label}": unsupported session type.`,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
protocol = "ssh";
|
||
hostname = sessionFields[1]?.trim() || undefined;
|
||
port = parsePort(sessionFields[2]);
|
||
const rawUsername = sessionFields[3]?.trim();
|
||
username = rawUsername && rawUsername !== "<default>" ? rawUsername : undefined;
|
||
} else {
|
||
// Retain support for the simpler token layouts accepted by older imports.
|
||
const tokens = rawValue
|
||
.split("#")
|
||
.map((t) => t.trim())
|
||
.filter(Boolean);
|
||
|
||
if (tokens.length > 0) {
|
||
protocol =
|
||
normalizeProtocol(tokens[0]) ??
|
||
tokens.map((t) => normalizeProtocol(t)).find(Boolean);
|
||
|
||
for (const tok of tokens) {
|
||
const target = parseTarget(tok.replace(/^ssh:/i, "").trim());
|
||
if (target) {
|
||
hostname = target.hostname;
|
||
username = target.username ?? username;
|
||
port = target.port ?? port;
|
||
protocol = target.protocol ?? protocol;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const numericPort = tokens.map((t) => parsePort(t)).find(Boolean);
|
||
if (numericPort) port = numericPort;
|
||
|
||
if (!username) {
|
||
const userToken = tokens.find((t) => t.includes("@"));
|
||
if (userToken) username = userToken.split("@")[0];
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!hostname) {
|
||
skipped++;
|
||
issues.push({
|
||
level: "warning",
|
||
message: `MobaXterm entry "${label}": missing hostname.`,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
parsedHosts.push(
|
||
createHost({
|
||
label,
|
||
hostname,
|
||
username,
|
||
port,
|
||
protocol: protocol ?? "ssh",
|
||
group,
|
||
}),
|
||
);
|
||
}
|
||
|
||
const { hosts: uniqueHosts, duplicates } = dedupeHosts(parsedHosts);
|
||
const attached = attachMobaXtermPasswords(uniqueHosts, {
|
||
passwords: passwordEntries,
|
||
credentials: credentialEntries,
|
||
sessionP: misc.get("sessionp"),
|
||
sysUsername: misc.get("mpsetaccount"),
|
||
sysHostname: misc.get("mpsetcomputer"),
|
||
passwordsInRegistry: misc.get("passwordsinregistry") === "1",
|
||
hasSesspass,
|
||
}, { masterPassword: options?.masterPassword });
|
||
issues.push(...attached.issues);
|
||
const hosts = attached.hosts;
|
||
const groups = uniq(hosts.map((h) => h.group).filter(Boolean) as string[]);
|
||
return {
|
||
hosts,
|
||
groups,
|
||
issues,
|
||
stats: { parsed, imported: hosts.length, skipped, duplicates },
|
||
};
|
||
};
|
||
|
||
export const importVaultHostsFromText = (
|
||
format: VaultImportFormat,
|
||
text: string,
|
||
options?: { fileName?: string; masterPassword?: string },
|
||
): VaultImportResult => {
|
||
const input = text ?? "";
|
||
switch (format) {
|
||
case "csv":
|
||
return importFromCsv(input);
|
||
case "putty":
|
||
return importFromPuttyReg(input);
|
||
case "ssh_config":
|
||
return importFromSshConfig(input);
|
||
case "securecrt":
|
||
return importFromSecureCrt(input, options?.fileName);
|
||
case "mobaxterm":
|
||
return importFromMobaXterm(input, options);
|
||
default: {
|
||
const _exhaustive: never = format;
|
||
return _exhaustive;
|
||
}
|
||
}
|
||
};
|
||
|
||
export function detectVaultImportFormat(text: string): VaultImportFormat | null {
|
||
const input = (text ?? "").trim();
|
||
if (!input) return null;
|
||
|
||
if (
|
||
/Windows Registry Editor Version/i.test(input)
|
||
|| /\\Software\\SimonTatham\\PuTTY\\Sessions/i.test(input)
|
||
) {
|
||
return "putty";
|
||
}
|
||
|
||
const hasMobaBookmarkSection = /^\[Bookmarks(?:_\d+)?\]\s*$/im.test(input);
|
||
const hasMobaBookmarkMetadata = /^SubRep=.*$/im.test(input) && /^ImgNum=\d+\s*$/im.test(input);
|
||
const hasMobaSessionLine = /^[^=\r\n]+=\s*(?:; logout)?\s*#\d+#\d+%[^%\r\n]+%\d+/im.test(input);
|
||
const hasMobaFullConfig = /^\[Misc\]\s*$/im.test(input)
|
||
&& (/^SessionP=/im.test(input) || /^\[Passwords\]\s*$/im.test(input) || /^\[Credentials\]\s*$/im.test(input));
|
||
if (
|
||
/\[MobaXterm\]/i.test(input)
|
||
|| (hasMobaBookmarkSection && (hasMobaBookmarkMetadata || hasMobaSessionLine || hasMobaFullConfig))
|
||
|| (hasMobaFullConfig && hasMobaBookmarkSection)
|
||
) {
|
||
return "mobaxterm";
|
||
}
|
||
|
||
if (/^Host\s+/m.test(input) && (/^\s+HostName\s+/m.test(input) || /^\s+User\s+/m.test(input))) {
|
||
return "ssh_config";
|
||
}
|
||
|
||
if (/S:"Hostname"/m.test(input) && (/S:"Username"/m.test(input) || /D:"\[Sessions\]/i.test(input))) {
|
||
return "securecrt";
|
||
}
|
||
|
||
const firstLine = input.split(/\r?\n/, 1)[0] ?? "";
|
||
if (
|
||
/hostname|host(name)?|server/i.test(firstLine)
|
||
&& (firstLine.includes(",") || firstLine.includes("\t"))
|
||
) {
|
||
return "csv";
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
export function applyVaultHostImport(
|
||
existingHosts: Host[],
|
||
existingGroups: string[],
|
||
importResult: VaultImportResult,
|
||
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 = importResult.hosts;
|
||
let skippedExistingCount = 0;
|
||
|
||
if (skipDuplicates) {
|
||
newHosts = importResult.hosts.filter((host) => {
|
||
const duplicate = existingKeys.has(buildVaultHostMergeKey(host));
|
||
if (duplicate) skippedExistingCount++;
|
||
return !duplicate;
|
||
});
|
||
}
|
||
|
||
const customGroups = Array.from(
|
||
new Set([
|
||
...existingGroups,
|
||
...importResult.groups,
|
||
...newHosts.map((host) => host.group).filter(Boolean),
|
||
]),
|
||
) as string[];
|
||
|
||
return {
|
||
hosts: [...existingHosts, ...newHosts].map(sanitizeHost),
|
||
customGroups,
|
||
addedCount: newHosts.length,
|
||
skippedExistingCount,
|
||
addedHosts: newHosts,
|
||
};
|
||
}
|
||
|
||
export async function resolveVaultImportKeyPassphraseConflicts(
|
||
entries: VaultHostKeyPassphrase[],
|
||
resolveAliases: (keyPath: string) => Promise<string[]>,
|
||
eligibleHostIds?: ReadonlySet<string>,
|
||
eligibleKeyPathsByHostId?: ReadonlyMap<string, string>,
|
||
): Promise<{ keyPassphrases: VaultHostKeyPassphrase[]; issues: VaultImportIssue[] }> {
|
||
const groups: Array<{
|
||
aliases: Set<string>;
|
||
entries: VaultHostKeyPassphrase[];
|
||
}> = [];
|
||
|
||
for (const entry of entries) {
|
||
const aliases = new Set((await resolveAliases(entry.keyPath)).map(normalizeKeyPathKey));
|
||
aliases.add(normalizeKeyPathKey(entry.keyPath));
|
||
const eligibleKeyPath = eligibleKeyPathsByHostId?.get(entry.hostId);
|
||
if (eligibleKeyPath) {
|
||
const eligibleAliases = new Set(
|
||
(await resolveAliases(eligibleKeyPath)).map(normalizeKeyPathKey),
|
||
);
|
||
eligibleAliases.add(normalizeKeyPathKey(eligibleKeyPath));
|
||
if (![...aliases].some((alias) => eligibleAliases.has(alias))) continue;
|
||
} else if (eligibleKeyPathsByHostId && eligibleHostIds?.has(entry.hostId)) {
|
||
continue;
|
||
}
|
||
const matching = groups.filter((group) => (
|
||
[...aliases].some((alias) => group.aliases.has(alias))
|
||
));
|
||
const mergedAliases = new Set([
|
||
...aliases,
|
||
...matching.flatMap((group) => [...group.aliases]),
|
||
]);
|
||
const mergedEntries = [
|
||
...matching.flatMap((group) => group.entries),
|
||
entry,
|
||
];
|
||
for (const group of matching) {
|
||
groups.splice(groups.indexOf(group), 1);
|
||
}
|
||
groups.push({ aliases: mergedAliases, entries: mergedEntries });
|
||
}
|
||
|
||
const keyPassphrases: VaultHostKeyPassphrase[] = [];
|
||
const issues: VaultImportIssue[] = [];
|
||
for (const group of groups) {
|
||
const passphrases = new Set(group.entries.map((entry) => entry.passphrase));
|
||
if (passphrases.size > 1) {
|
||
issues.push({
|
||
level: "warning",
|
||
message: `CSV contains conflicting passphrases for KeyPath "${group.entries[0].keyPath}"; no passphrase was saved for that path.`,
|
||
});
|
||
} else {
|
||
const selected = eligibleHostIds
|
||
? group.entries.find((entry) => eligibleHostIds.has(entry.hostId))
|
||
: group.entries[0];
|
||
if (selected) keyPassphrases.push(selected);
|
||
}
|
||
}
|
||
return { keyPassphrases, issues };
|
||
}
|
||
|
||
export interface VaultExistingKeyPassphraseRead {
|
||
values: string[];
|
||
unreadable: boolean;
|
||
}
|
||
|
||
export async function filterVaultImportKeyPassphrasesAgainstExisting(
|
||
entries: VaultHostKeyPassphrase[],
|
||
readExisting: (keyPath: string) => Promise<VaultExistingKeyPassphraseRead>,
|
||
): Promise<{ keyPassphrases: VaultHostKeyPassphrase[]; issues: VaultImportIssue[] }> {
|
||
const keyPassphrases: VaultHostKeyPassphrase[] = [];
|
||
const issues: VaultImportIssue[] = [];
|
||
for (const entry of entries) {
|
||
const existing = await readExisting(entry.keyPath);
|
||
if (existing.unreadable) {
|
||
issues.push({
|
||
level: "warning",
|
||
message: `Could not verify the existing saved passphrase for KeyPath "${entry.keyPath}"; the imported passphrase was not saved.`,
|
||
});
|
||
continue;
|
||
}
|
||
if (existing.values.some((value) => value !== entry.passphrase)) {
|
||
issues.push({
|
||
level: "warning",
|
||
message: `CSV passphrase conflicts with an existing saved passphrase for KeyPath "${entry.keyPath}"; the existing passphrase was kept.`,
|
||
});
|
||
continue;
|
||
}
|
||
keyPassphrases.push(entry);
|
||
}
|
||
return { keyPassphrases, issues };
|
||
}
|