mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-12 01:00:54 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
913293f839 | ||
|
|
cbba6dcfa4 | ||
|
|
f98defead5 | ||
|
|
6564d2c934 | ||
|
|
2d49600c07 | ||
|
|
29d7a3f2c0 |
No files matched your search
@@ -33,6 +33,7 @@ import {
|
||||
filterVaultImportKeyPassphrasesAgainstExisting,
|
||||
mergeVaultImportIssues,
|
||||
resolveVaultImportKeyPassphraseConflicts,
|
||||
toVaultImportExistingHostRef,
|
||||
type VaultImportFormat,
|
||||
} from "../../domain/vaultImport";
|
||||
import type { GroupConfig, Host, ManagedSource, SSHKey } from "../../types";
|
||||
@@ -175,6 +176,7 @@ export function useVaultImportHandlers({
|
||||
files,
|
||||
encoding: options?.encoding,
|
||||
masterPassword: options?.masterPassword,
|
||||
existingHosts: hostsRef.current.map(toVaultImportExistingHostRef),
|
||||
signal,
|
||||
onProgress: (progress) => {
|
||||
if (!signal.aborted) updateProgress(progress);
|
||||
|
||||
+265
-10
@@ -14,8 +14,9 @@ export {
|
||||
buildVaultHostMergeKey,
|
||||
} from "./vaultHostCreate";
|
||||
import { parseQuickConnectInput } from "./quickConnect";
|
||||
import { canServeAsSshJumpHost } from "./terminalProtocol";
|
||||
import { findExactHeaderIndex, findHeaderIndex, parseCsv } from "./vaultImport/csvUtils";
|
||||
import { decodeCsvKeyPath, decodeCsvLocalShell, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
|
||||
import { decodeCsvKeyPath, decodeCsvLocalShell, decodeCsvPassphrase, decodeCsvProxyJump } from "./vaultImport/csvCredentialFields";
|
||||
import { isCsvDirectProxyValue, parseCsvProxy } from "./vaultImport/csvProxy";
|
||||
import { attachMobaXtermPasswords } from "./vaultImport/mobaXtermPasswords";
|
||||
import { decodeFinalShellPassword } from "./finalShellPassword";
|
||||
@@ -100,6 +101,29 @@ const parseProxyJump = (value: string): ParsedJumpHost[] => {
|
||||
.filter((h): h is ParsedJumpHost => h !== null);
|
||||
};
|
||||
|
||||
/** Vault host fields needed to bind a CSV ProxyJump hop without copying secrets. */
|
||||
export interface VaultImportExistingHostRef {
|
||||
id: string;
|
||||
label: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
protocol?: Host["protocol"];
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export const toVaultImportExistingHostRef = (
|
||||
host: Pick<Host, "id" | "label" | "hostname" | "port" | "username" | "protocol" | "group">,
|
||||
): VaultImportExistingHostRef => ({
|
||||
id: host.id,
|
||||
label: host.label,
|
||||
hostname: host.hostname,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
protocol: host.protocol,
|
||||
group: host.group,
|
||||
});
|
||||
|
||||
export type VaultImportFormat =
|
||||
| "putty"
|
||||
| "mobaxterm"
|
||||
@@ -254,9 +278,32 @@ export function applyVaultImportDestination(
|
||||
return remapped;
|
||||
};
|
||||
|
||||
// Collapsing two group copies must not leave a chain pointed at the dropped id.
|
||||
// Ids that are not part of this import (an existing vault hop) stay as they are.
|
||||
const remappedHosts = hosts.map((host) => {
|
||||
const currentIds = host.hostChain?.hostIds;
|
||||
if (!currentIds?.length) return host;
|
||||
const hostIds: string[] = [];
|
||||
for (const hostId of currentIds) {
|
||||
const mapped = remapHostId(hostId);
|
||||
const nextId = mapped ?? (originalById.has(hostId) ? undefined : hostId);
|
||||
if (!nextId || nextId === host.id || hostIds.includes(nextId)) continue;
|
||||
hostIds.push(nextId);
|
||||
}
|
||||
if (hostIds.length === currentIds.length && hostIds.every((id, index) => id === currentIds[index])) {
|
||||
return host;
|
||||
}
|
||||
if (hostIds.length === 0) {
|
||||
const next = { ...host };
|
||||
delete next.hostChain;
|
||||
return next;
|
||||
}
|
||||
return { ...host, hostChain: { hostIds } };
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
hosts,
|
||||
hosts: remappedHosts,
|
||||
groups: [group],
|
||||
keyPassphrases: remapKeyPassphrases(result.keyPassphrases, { matchSelectedKey: true }),
|
||||
// Keep remapped candidates for alias conflict checks (same as CSV same-group
|
||||
@@ -421,8 +468,176 @@ const parseTarget = (
|
||||
return null;
|
||||
};
|
||||
|
||||
const CSV_JUMP_PORT = 22;
|
||||
|
||||
const importFromCsv = (text: string): VaultImportResult => {
|
||||
const csvJumpPort = (port: number | undefined): number => (
|
||||
port !== undefined && Number.isInteger(port) && port >= 1 && port <= 65535 ? port : CSV_JUMP_PORT
|
||||
);
|
||||
|
||||
type CsvJumpCandidate = {
|
||||
id: string;
|
||||
label: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
protocol?: Host["protocol"];
|
||||
group?: string;
|
||||
};
|
||||
|
||||
const csvJumpEndpointMatches = (hop: ParsedJumpHost, candidate: CsvJumpCandidate): boolean => {
|
||||
if (!canServeAsSshJumpHost(candidate)) return false;
|
||||
if (candidate.hostname.trim().toLowerCase() !== hop.hostname.toLowerCase()) return false;
|
||||
if (csvJumpPort(candidate.port) !== csvJumpPort(hop.port)) return false;
|
||||
if (hop.username && (candidate.username ?? "").trim().toLowerCase() !== hop.username.toLowerCase()) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const csvJumpLabelMatches = (hop: ParsedJumpHost, candidate: CsvJumpCandidate): boolean => {
|
||||
if (!canServeAsSshJumpHost(candidate)) return false;
|
||||
const label = candidate.label.trim().toLowerCase();
|
||||
if (!label || label !== hop.hostname.toLowerCase()) return false;
|
||||
if (csvJumpPort(candidate.port) !== csvJumpPort(hop.port)) return false;
|
||||
if (hop.username && (candidate.username ?? "").trim().toLowerCase() !== hop.username.toLowerCase()) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const pickCsvJumpCandidate = <T extends { group?: string }>(matches: readonly T[], group: string | undefined): T => (
|
||||
matches.find((candidate) => (candidate.group ?? "") === (group ?? "")) ?? matches[0]
|
||||
);
|
||||
|
||||
const attachCsvProxyJumpChains = (
|
||||
hosts: Host[],
|
||||
proxyJumpByHost: ReadonlyMap<Host, { value: string; row: number }>,
|
||||
issues: VaultImportIssue[],
|
||||
existingHosts: readonly VaultImportExistingHostRef[],
|
||||
): Host[] => {
|
||||
const inlineHosts: Host[] = [];
|
||||
const matchable: CsvJumpCandidate[] = [...hosts];
|
||||
|
||||
const resolveHop = (hop: ParsedJumpHost, group: string | undefined): string | null => {
|
||||
const endpointMatches = [
|
||||
...matchable.filter((candidate) => csvJumpEndpointMatches(hop, candidate)),
|
||||
...existingHosts.filter((candidate) => csvJumpEndpointMatches(hop, candidate)),
|
||||
];
|
||||
if (endpointMatches.length > 0) return pickCsvJumpCandidate(endpointMatches, group).id;
|
||||
const labelMatches = [
|
||||
...matchable.filter((candidate) => csvJumpLabelMatches(hop, candidate)),
|
||||
...existingHosts.filter((candidate) => csvJumpLabelMatches(hop, candidate)),
|
||||
];
|
||||
if (labelMatches.length > 0) return pickCsvJumpCandidate(labelMatches, group).id;
|
||||
|
||||
const inlineKey = `${hop.hostname.toLowerCase()}|${csvJumpPort(hop.port)}|${(hop.username ?? "").toLowerCase()}`;
|
||||
const existingInline = inlineHosts.find((host) => (
|
||||
`${host.hostname.toLowerCase()}|${csvJumpPort(host.port)}|${(host.username ?? "").toLowerCase()}` === inlineKey
|
||||
));
|
||||
if (existingInline) return existingInline.id;
|
||||
if (!looksLikeHostnameToken(hop.hostname)) return null;
|
||||
try {
|
||||
const inlineHost = createHost({
|
||||
label: hop.hostname,
|
||||
hostname: hop.hostname,
|
||||
username: hop.username,
|
||||
port: hop.port,
|
||||
protocol: "ssh",
|
||||
});
|
||||
inlineHosts.push(inlineHost);
|
||||
matchable.push(inlineHost);
|
||||
return inlineHost.id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
for (const host of hosts) {
|
||||
const entry = proxyJumpByHost.get(host);
|
||||
if (!entry) continue;
|
||||
const specs = entry.value
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part && part.toLowerCase() !== "none");
|
||||
if (specs.length === 0) continue;
|
||||
|
||||
const resolvedIds: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
const inlineCountBefore = inlineHosts.length;
|
||||
let chainRejected = false;
|
||||
for (const spec of specs) {
|
||||
const jump = parseJumpHostSpec(spec);
|
||||
if (!jump) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV row ${entry.row}: ProxyJump hop "${spec}" was ignored because it is not a valid jump host.`,
|
||||
});
|
||||
chainRejected = true;
|
||||
continue;
|
||||
}
|
||||
const inlineCount = inlineHosts.length;
|
||||
const resolvedId = resolveHop(jump, host.group);
|
||||
if (!resolvedId) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV row ${entry.row}: ProxyJump hop "${spec}" was ignored because it is not a valid jump host.`,
|
||||
});
|
||||
chainRejected = true;
|
||||
continue;
|
||||
}
|
||||
if (inlineHosts.length > inlineCount) unresolved.push(jump.hostname);
|
||||
if (!resolvedIds.includes(resolvedId)) resolvedIds.push(resolvedId);
|
||||
}
|
||||
|
||||
if (chainRejected || resolvedIds.includes(host.id)) {
|
||||
if (inlineHosts.length > inlineCountBefore) {
|
||||
const droppedIds = new Set(inlineHosts.splice(inlineCountBefore).map((item) => item.id));
|
||||
for (let index = matchable.length - 1; index >= 0; index -= 1) {
|
||||
if (droppedIds.has(matchable[index].id)) matchable.splice(index, 1);
|
||||
}
|
||||
}
|
||||
if (resolvedIds.includes(host.id)) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV: detected circular reference in ProxyJump for "${host.label}", skipping chain.`,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (resolvedIds.length > 0) host.hostChain = { hostIds: resolvedIds };
|
||||
if (unresolved.length > 0) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV: created inline jump host(s) for "${host.label}": ${unresolved.join(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const allHosts = [...hosts, ...inlineHosts];
|
||||
const participatesInCycle = (hostId: string, visited: Set<string>): boolean => {
|
||||
if (visited.has(hostId)) return true;
|
||||
visited.add(hostId);
|
||||
const current = allHosts.find((item) => item.id === hostId);
|
||||
for (const chainId of current?.hostChain?.hostIds ?? []) {
|
||||
if (!allHosts.some((item) => item.id === chainId)) continue;
|
||||
if (participatesInCycle(chainId, visited)) return true;
|
||||
}
|
||||
visited.delete(hostId);
|
||||
return false;
|
||||
};
|
||||
const cyclicHosts = allHosts.filter((host) => (
|
||||
Boolean(host.hostChain?.hostIds?.length) && participatesInCycle(host.id, new Set())
|
||||
));
|
||||
for (const host of cyclicHosts) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV: detected circular dependency in jump chain for "${host.label}", removing chain.`,
|
||||
});
|
||||
delete host.hostChain;
|
||||
}
|
||||
return allHosts;
|
||||
};
|
||||
|
||||
const importFromCsv = (
|
||||
text: string,
|
||||
existingHosts: readonly VaultImportExistingHostRef[] = [],
|
||||
): VaultImportResult => {
|
||||
const issues: VaultImportIssue[] = [];
|
||||
const rows = parseCsv(text).filter((r) => r.some((c) => c.trim() !== ""));
|
||||
if (rows.length === 0) {
|
||||
@@ -448,6 +663,7 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
const keyPathIdx = findExactHeaderIndex(header, ["keypath", "key path", "identityfile", "identity file"]);
|
||||
const explicitPassphraseIdx = findExactHeaderIndex(header, ["passphrase", "keypassphrase", "key passphrase"]);
|
||||
const proxyIdx = findExactHeaderIndex(header, ["proxy", "proxyserver", "proxy server"]);
|
||||
const proxyJumpIdx = findExactHeaderIndex(header, ["proxyjump"]);
|
||||
const localShellIdx = findExactHeaderIndex(header, ["localshell"]);
|
||||
const passphraseIdx = keyPathIdx >= 0 ? explicitPassphraseIdx : -1;
|
||||
const exactPasswordIdx = findExactHeaderIndex(header, ["password", "pass", "passwd"]);
|
||||
@@ -482,6 +698,7 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
}
|
||||
|
||||
const parsedHosts: Host[] = [];
|
||||
const proxyJumpByHost = new Map<Host, { value: string; row: number }>();
|
||||
const keyPassphraseCandidates: Array<{
|
||||
hostKey: string;
|
||||
keyPathKey: string;
|
||||
@@ -549,6 +766,8 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
message: `CSV row ${i + 2}: LocalShell was ignored because it is not a valid encoded local-shell value.`,
|
||||
});
|
||||
}
|
||||
const proxyJumpRaw = (proxyJumpIdx >= 0 ? row[proxyJumpIdx] : undefined)?.trim();
|
||||
const proxyJump = proxyJumpRaw ? decodeCsvProxyJump(proxyJumpRaw).trim() : "";
|
||||
|
||||
if (decodedPassphrase && isEncryptedCredentialPlaceholder(decodedPassphrase)) {
|
||||
issues.push({
|
||||
@@ -586,6 +805,9 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
if (localShellSpec.startDir) host.localStartDir = localShellSpec.startDir;
|
||||
if (localShellSpec.os) host.os = localShellSpec.os;
|
||||
}
|
||||
if (proxyJump && proxyJump.toLowerCase() !== "none") {
|
||||
proxyJumpByHost.set(host, { value: proxyJump, row: i + 2 });
|
||||
}
|
||||
parsedHosts.push(host);
|
||||
if (keyPath && passphrase) {
|
||||
const keyPathKey = normalizeKeyPathKey(keyPath);
|
||||
@@ -612,7 +834,8 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
// the shell identity is part of buildVaultHostMergeKey; otherwise importing a
|
||||
// backup with CMD and PowerShell in the same group silently keeps only the
|
||||
// first shell and flags the rest as duplicates.
|
||||
const { hosts, duplicates } = dedupeHosts(parsedHosts);
|
||||
const { hosts: dedupedHosts, duplicates } = dedupeHosts(parsedHosts);
|
||||
const hosts = attachCsvProxyJumpChains(dedupedHosts, proxyJumpByHost, issues, existingHosts);
|
||||
const keyPassphrases = hosts.flatMap((host) => {
|
||||
const selectedKeyPath = host.identityFilePaths?.find((path) => path.trim())?.trim();
|
||||
if (!selectedKeyPath) return [];
|
||||
@@ -1409,15 +1632,22 @@ const importFromMobaXterm = (
|
||||
};
|
||||
};
|
||||
|
||||
export interface VaultImportTextOptions {
|
||||
fileName?: string;
|
||||
masterPassword?: string;
|
||||
/** Hosts already in the vault. CSV ProxyJump hops bind to these by endpoint. */
|
||||
existingHosts?: readonly VaultImportExistingHostRef[];
|
||||
}
|
||||
|
||||
export const importVaultHostsFromText = (
|
||||
format: VaultImportFormat,
|
||||
text: string,
|
||||
options?: { fileName?: string; masterPassword?: string },
|
||||
options?: VaultImportTextOptions,
|
||||
): VaultImportResult => {
|
||||
const input = text ?? "";
|
||||
switch (format) {
|
||||
case "csv":
|
||||
return importFromCsv(input);
|
||||
return importFromCsv(input, options?.existingHosts);
|
||||
case "putty":
|
||||
return importFromPuttyReg(input);
|
||||
case "ssh_config":
|
||||
@@ -1508,15 +1738,40 @@ export function applyVaultHostImport(
|
||||
addedHosts: Host[];
|
||||
} {
|
||||
const skipDuplicates = options?.skipDuplicates !== false;
|
||||
const existingKeys = new Set(existingHosts.map(buildVaultHostMergeKey));
|
||||
const existingByKey = new Map(existingHosts.map((host) => [buildVaultHostMergeKey(host), host]));
|
||||
const droppedIdToExistingId = new Map<string, string>();
|
||||
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 existing = existingByKey.get(buildVaultHostMergeKey(host));
|
||||
if (!existing) return true;
|
||||
skippedExistingCount++;
|
||||
if (existing.id !== host.id) droppedIdToExistingId.set(host.id, existing.id);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (droppedIdToExistingId.size > 0) {
|
||||
newHosts = newHosts.map((host) => {
|
||||
const currentIds = host.hostChain?.hostIds;
|
||||
if (!currentIds?.length) return host;
|
||||
const hostIds: string[] = [];
|
||||
for (const hostId of currentIds) {
|
||||
const mapped = droppedIdToExistingId.get(hostId) ?? hostId;
|
||||
if (mapped === host.id || hostIds.includes(mapped)) continue;
|
||||
hostIds.push(mapped);
|
||||
}
|
||||
if (hostIds.length === currentIds.length && hostIds.every((id, index) => id === currentIds[index])) {
|
||||
return host;
|
||||
}
|
||||
if (hostIds.length === 0) {
|
||||
const next = { ...host };
|
||||
delete next.hostChain;
|
||||
return next;
|
||||
}
|
||||
return { ...host, hostChain: { hostIds } };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ const FORMULA_PREFIX = /^[=+\-@\t\r]/u;
|
||||
const KEY_PATH_MARKER = "__netcatty_csv_keypath_v1__:";
|
||||
const PASSPHRASE_MARKER = "__netcatty_csv_passphrase_v1__:";
|
||||
const PROXY_MARKER = "__netcatty_csv_proxy_v1__:";
|
||||
const PROXY_JUMP_MARKER = "__netcatty_csv_proxyjump_v1__:";
|
||||
const LOCAL_SHELL_MARKER = "__netcatty_csv_localshell_v1__:";
|
||||
|
||||
const encodeMarkedField = (value: string, marker: string): string => (
|
||||
@@ -43,6 +44,14 @@ export const decodeCsvProxy = (value: string): string => (
|
||||
decodeMarkedField(value, PROXY_MARKER)
|
||||
);
|
||||
|
||||
export const encodeCsvProxyJump = (value: string): string => (
|
||||
encodeMarkedField(value, PROXY_JUMP_MARKER)
|
||||
);
|
||||
|
||||
export const decodeCsvProxyJump = (value: string): string => (
|
||||
decodeMarkedField(value, PROXY_JUMP_MARKER)
|
||||
);
|
||||
|
||||
export interface CsvLocalShellSpec {
|
||||
shell?: string;
|
||||
shellArgs?: string[];
|
||||
|
||||
@@ -3,8 +3,9 @@ import test from "node:test";
|
||||
|
||||
import { publishActiveGlobalDefaultProxyProfileId } from "../globalDefaultProxy.ts";
|
||||
import { applyGroupDefaults } from "../groupConfig.ts";
|
||||
import { importVaultHostsFromText } from "../vaultImport.ts";
|
||||
import { applyVaultHostImport, applyVaultImportDestination, importVaultHostsFromText } from "../vaultImport.ts";
|
||||
import { exportHostsToCsvWithStats, getVaultCsvTemplate } from "./csvExport.ts";
|
||||
import { parseCsv } from "./csvUtils.ts";
|
||||
import type { Host } from "../models.ts";
|
||||
|
||||
const hostDefaults = { username: "root", tags: [], os: "linux" as const };
|
||||
@@ -606,6 +607,230 @@ test("CSV round-trips local-shell vault host settings", () => {
|
||||
assert.equal(roundTrippedDefault.localStartDir, undefined);
|
||||
});
|
||||
|
||||
const proxyJumpCell = (csv: string, hostname: string): string => {
|
||||
const rows = parseCsv(csv.replace(/^\uFEFF/, "")).filter((row) => row.some((cell) => cell.trim() !== ""));
|
||||
const header = rows[0] ?? [];
|
||||
const jumpIndex = header.indexOf("ProxyJump");
|
||||
const hostIndex = header.findIndex((cell) => cell.toLowerCase().includes("hostname"));
|
||||
const row = rows.slice(1).find((cells) => cells[hostIndex] === hostname);
|
||||
return jumpIndex >= 0 ? (row?.[jumpIndex] ?? "") : "";
|
||||
};
|
||||
|
||||
const chainHost = (id: string, label: string, hostname: string, username: string, port = 22): Host => ({
|
||||
...hostDefaults,
|
||||
id,
|
||||
label,
|
||||
hostname,
|
||||
username,
|
||||
port,
|
||||
});
|
||||
|
||||
test("CSV export writes one-hop and two-hop ProxyJump chains", () => {
|
||||
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
|
||||
const second = chainHost("jump-2", "Second hop", "jump.example.com", "bob", 2222);
|
||||
const v6 = chainHost("jump-v6", "IPv6 hop", "2001:db8::1", "carol", 2222);
|
||||
const oneHop = chainHost("app-1", "One hop", "one.example.com", "root");
|
||||
oneHop.hostChain = { hostIds: [bastion.id] };
|
||||
const twoHop = chainHost("app-2", "Two hop", "two.example.com", "root");
|
||||
twoHop.hostChain = { hostIds: [bastion.id, second.id] };
|
||||
const viaV6 = chainHost("app-v6", "Via IPv6", "v6.example.com", "root");
|
||||
viaV6.hostChain = { hostIds: [v6.id] };
|
||||
const direct = chainHost("app-3", "Direct", "direct.example.com", "root");
|
||||
|
||||
const { csv } = exportHostsToCsvWithStats([bastion, second, v6, oneHop, twoHop, viaV6, direct]);
|
||||
|
||||
assert.equal(proxyJumpCell(csv, bastion.hostname), "");
|
||||
assert.equal(proxyJumpCell(csv, oneHop.hostname), "alice@bastion.example.com");
|
||||
assert.equal(proxyJumpCell(csv, twoHop.hostname), "alice@bastion.example.com,bob@jump.example.com:2222");
|
||||
assert.equal(proxyJumpCell(csv, viaV6.hostname), "carol@[2001:db8::1]:2222");
|
||||
assert.equal(proxyJumpCell(csv, direct.hostname), "");
|
||||
assert.match(getVaultCsvTemplate().split(/\r?\n/, 1)[0] ?? "", /(^|,)ProxyJump(,|$)/);
|
||||
});
|
||||
|
||||
test("CSV round-trips a jump host chain onto the imported hosts", () => {
|
||||
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
|
||||
const second = chainHost("jump-2", "Second hop", "jump.example.com", "bob", 2222);
|
||||
const target = chainHost("app", "App", "app.example.com", "root");
|
||||
target.group = "Prod";
|
||||
target.hostChain = { hostIds: [bastion.id, second.id] };
|
||||
|
||||
const { csv } = exportHostsToCsvWithStats([bastion, second, target]);
|
||||
const imported = importVaultHostsFromText("csv", csv);
|
||||
const byHostname = new Map(imported.hosts.map((host) => [host.hostname, host]));
|
||||
const importedTarget = byHostname.get(target.hostname);
|
||||
const importedBastion = byHostname.get(bastion.hostname);
|
||||
const importedSecond = byHostname.get(second.hostname);
|
||||
|
||||
assert.equal(imported.issues.length, 0);
|
||||
assert.deepEqual(importedTarget?.hostChain?.hostIds, [importedBastion?.id, importedSecond?.id]);
|
||||
assert.equal(importedBastion?.hostChain, undefined);
|
||||
assert.equal(importedSecond?.port, 2222);
|
||||
assert.equal(importedSecond?.username, "bob");
|
||||
});
|
||||
|
||||
test("CSV import creates an inline jump host when the hop is not in the file", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"app.example.com,root,alice@bastion.example.com:2222",
|
||||
].join("\n"));
|
||||
const target = imported.hosts.find((host) => host.hostname === "app.example.com");
|
||||
const jump = imported.hosts.find((host) => host.hostname === "bastion.example.com");
|
||||
|
||||
assert.equal(imported.hosts.length, 2);
|
||||
assert.equal(jump?.username, "alice");
|
||||
assert.equal(jump?.port, 2222);
|
||||
assert.equal(jump?.protocol, "ssh");
|
||||
assert.deepEqual(target?.hostChain?.hostIds, jump ? [jump.id] : []);
|
||||
assert.ok(imported.issues.some((issue) => issue.level === "warning" && /inline jump host/i.test(issue.message)));
|
||||
});
|
||||
|
||||
test("CSV import binds a missing hop to an existing vault host by endpoint", () => {
|
||||
const existing = chainHost("vault-jump", "Old bastion", "bastion.example.com", "alice", 2222);
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"app.example.com,root,alice@bastion.example.com:2222",
|
||||
].join("\n"), { existingHosts: [existing] });
|
||||
|
||||
assert.equal(imported.hosts.length, 1);
|
||||
assert.equal(imported.hosts[0]?.hostname, "app.example.com");
|
||||
assert.deepEqual(imported.hosts[0]?.hostChain, { hostIds: [existing.id] });
|
||||
assert.deepEqual(imported.issues, []);
|
||||
|
||||
const moved = applyVaultImportDestination(imported, { mode: "group", group: "Moved" });
|
||||
assert.deepEqual(moved.hosts[0]?.hostChain, { hostIds: [existing.id] });
|
||||
});
|
||||
|
||||
test("CSV import keeps an old file without a ProxyJump column unchanged", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,Password",
|
||||
"legacy.example.com,root,secret",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(imported.hosts.length, 1);
|
||||
assert.equal(imported.hosts[0]?.hostname, "legacy.example.com");
|
||||
assert.equal(imported.hosts[0]?.username, "root");
|
||||
assert.equal(imported.hosts[0]?.password, "secret");
|
||||
assert.equal(imported.hosts[0]?.hostChain, undefined);
|
||||
assert.deepEqual(imported.issues, []);
|
||||
});
|
||||
|
||||
test("CSV import retargets a chain when the jump host is already in the vault", () => {
|
||||
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
|
||||
bastion.group = "Prod";
|
||||
const target = chainHost("app", "App", "app.example.com", "root");
|
||||
target.group = "Prod";
|
||||
target.hostChain = { hostIds: [bastion.id] };
|
||||
const existing = chainHost("vault-jump", "Bastion", "bastion.example.com", "alice");
|
||||
existing.group = "Prod";
|
||||
|
||||
const { csv } = exportHostsToCsvWithStats([bastion, target]);
|
||||
const imported = importVaultHostsFromText("csv", csv, { existingHosts: [existing] });
|
||||
const merged = applyVaultHostImport([existing], ["Prod"], imported);
|
||||
|
||||
assert.equal(merged.addedHosts.length, 1);
|
||||
assert.equal(merged.addedHosts[0]?.hostname, "app.example.com");
|
||||
assert.deepEqual(merged.addedHosts[0]?.hostChain, { hostIds: [existing.id] });
|
||||
assert.equal(merged.hosts.some((host) => host.id !== existing.id && host.hostname === bastion.hostname), false);
|
||||
});
|
||||
|
||||
test("CSV export materializes a jump chain inherited from group defaults", () => {
|
||||
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
|
||||
const target = chainHost("app", "App", "app.example.com", "root");
|
||||
target.group = "Corp";
|
||||
|
||||
const { csv } = exportHostsToCsvWithStats([bastion, target], {
|
||||
groupConfigs: [{ path: "Corp", hostChain: { hostIds: [bastion.id] } }],
|
||||
});
|
||||
|
||||
assert.equal(proxyJumpCell(csv, target.hostname), "alice@bastion.example.com");
|
||||
});
|
||||
|
||||
test("CSV import warns and drops a circular ProxyJump chain", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"a.example.com,root,b.example.com",
|
||||
"b.example.com,root,a.example.com",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(imported.hosts.length, 2);
|
||||
assert.equal(imported.hosts.every((host) => host.hostChain === undefined), true);
|
||||
assert.ok(imported.issues.some((issue) => issue.level === "warning" && /circular/i.test(issue.message)));
|
||||
});
|
||||
|
||||
test("CSV import drops inline hops created only for a rejected self-referential ProxyJump", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"ok.example.com,root,kept-hop.example.com",
|
||||
"app.example.com,root,\"new-hop.example.com,app.example.com\"",
|
||||
].join("\n"));
|
||||
|
||||
const hostnames = imported.hosts.map((host) => host.hostname).sort();
|
||||
assert.deepEqual(hostnames, ["app.example.com", "kept-hop.example.com", "ok.example.com"]);
|
||||
const app = imported.hosts.find((host) => host.hostname === "app.example.com");
|
||||
const ok = imported.hosts.find((host) => host.hostname === "ok.example.com");
|
||||
const kept = imported.hosts.find((host) => host.hostname === "kept-hop.example.com");
|
||||
assert.equal(app?.hostChain, undefined);
|
||||
assert.deepEqual(ok?.hostChain?.hostIds, kept ? [kept.id] : []);
|
||||
assert.ok(imported.issues.some((issue) => issue.level === "warning" && /circular/i.test(issue.message)));
|
||||
});
|
||||
|
||||
test("CSV import prefers a same-group existing hop over a cross-group imported match", () => {
|
||||
const existing = chainHost("vault-jump", "Bastion", "bastion.example.com", "alice");
|
||||
existing.group = "Prod";
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Hostname,Username,ProxyJump",
|
||||
"Other,bastion.example.com,alice,",
|
||||
"Prod,app.example.com,root,alice@bastion.example.com",
|
||||
].join("\n"), { existingHosts: [existing] });
|
||||
|
||||
const app = imported.hosts.find((host) => host.hostname === "app.example.com");
|
||||
const importedBastion = imported.hosts.find((host) => host.hostname === "bastion.example.com");
|
||||
assert.equal(importedBastion?.group, "Other");
|
||||
assert.notEqual(importedBastion?.id, existing.id);
|
||||
assert.deepEqual(app?.hostChain, { hostIds: [existing.id] });
|
||||
});
|
||||
|
||||
test("CSV import prefers a same-group existing label hop over a cross-group imported label", () => {
|
||||
const existing = chainHost("vault-jump", "Bastion", "vault-bastion.internal", "alice");
|
||||
existing.group = "Prod";
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Username,ProxyJump",
|
||||
"Other,Bastion,other-bastion.internal,alice,",
|
||||
"Prod,App,app.example.com,root,alice@Bastion",
|
||||
].join("\n"), { existingHosts: [existing] });
|
||||
|
||||
const app = imported.hosts.find((host) => host.hostname === "app.example.com");
|
||||
const importedBastion = imported.hosts.find((host) => host.hostname === "other-bastion.internal");
|
||||
assert.equal(importedBastion?.group, "Other");
|
||||
assert.equal(importedBastion?.label, "Bastion");
|
||||
assert.notEqual(importedBastion?.id, existing.id);
|
||||
assert.deepEqual(app?.hostChain, { hostIds: [existing.id] });
|
||||
});
|
||||
|
||||
test("CSV import warns and ignores a ProxyJump value that is not a jump host", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"app.example.com,root,not a hop",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(imported.hosts.length, 1);
|
||||
assert.equal(imported.hosts[0]?.hostChain, undefined);
|
||||
assert.ok(imported.issues.some((issue) => issue.level === "warning" && issue.message.includes("ProxyJump")));
|
||||
});
|
||||
|
||||
test("CSV import rejects a ProxyJump chain that contains a malformed hop", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"app.example.com,root,\"bastion.example.com,not a hop\"",
|
||||
].join("\n"));
|
||||
|
||||
assert.deepEqual(imported.hosts.map((host) => host.hostname), ["app.example.com"]);
|
||||
assert.equal(imported.hosts[0]?.hostChain, undefined);
|
||||
assert.ok(imported.issues.some((issue) => (
|
||||
issue.level === "warning" && issue.message.includes("not a hop")
|
||||
)));
|
||||
});
|
||||
|
||||
test("CSV import warns and ignores a malformed LocalShell value", () => {
|
||||
const csv = [
|
||||
"Groups,Label,Tags,Notes,Hostname/IP,Protocol,Port,Username,Password,KeyPath,Passphrase,Proxy,LocalShell",
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { GroupConfig, Host, Identity, ProxyProfile } from '../models';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from '../groupConfig';
|
||||
import { isPluginHostProtocol } from '../pluginConnection';
|
||||
import { hasUnreadableProxyCredential, materializeHostProxyProfile, resolveProxyConfigAuth } from '../proxyProfiles';
|
||||
import { CsvLocalShellSpec, encodeCsvKeyPath, encodeCsvLocalShell, encodeCsvPassphrase, encodeCsvProxy } from './csvCredentialFields';
|
||||
import { canServeAsSshJumpHost } from '../terminalProtocol';
|
||||
import { CsvLocalShellSpec, encodeCsvKeyPath, encodeCsvLocalShell, encodeCsvPassphrase, encodeCsvProxy, encodeCsvProxyJump } from './csvCredentialFields';
|
||||
import { CSV_DIRECT_PROXY_VALUE, formatCsvProxy } from './csvProxy';
|
||||
|
||||
const UTF8_BOM = "\uFEFF";
|
||||
@@ -36,7 +37,7 @@ export const getVaultCsvTemplate = (
|
||||
opts: VaultCsvTemplateOptions = {},
|
||||
): string => {
|
||||
const includeExampleRows = opts.includeExampleRows !== false;
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "LocalShell"];
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
|
||||
const rows: string[][] = [header];
|
||||
if (includeExampleRows) {
|
||||
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "Dev web tier", "192.168.1.10", "ssh", "22", "root", "", "~/.ssh/id_ed25519", "", "socks5://127.0.0.1:1080"]);
|
||||
@@ -77,8 +78,44 @@ const getLocalShellSpec = (host: Host): CsvLocalShellSpec | null => (
|
||||
: null
|
||||
);
|
||||
|
||||
const UNSAFE_PROXY_JUMP_USERNAME = /[\s,#@]/;
|
||||
const UNSAFE_PROXY_JUMP_HOSTNAME = /[\s,#@]/;
|
||||
const DEFAULT_PROXY_JUMP_PORT = 22;
|
||||
|
||||
// OpenSSH ProxyJump: [user@]host[:port], repeated. Port 22 is omitted.
|
||||
// IPv6 is bracketed so the port separator stays unambiguous.
|
||||
const formatCsvProxyJumpHop = (host: Host): string | null => {
|
||||
if (!canServeAsSshJumpHost(host)) return null;
|
||||
const username = host.username?.trim() ?? "";
|
||||
const hostname = host.hostname.trim().replace(/^\[(.*)\]$/u, "$1");
|
||||
if (!hostname) return null;
|
||||
if (username && UNSAFE_PROXY_JUMP_USERNAME.test(username)) return null;
|
||||
if (UNSAFE_PROXY_JUMP_HOSTNAME.test(hostname)) return null;
|
||||
const port = host.port ?? DEFAULT_PROXY_JUMP_PORT;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
||||
const hostPart = hostname.includes(":") ? `[${hostname}]` : hostname;
|
||||
const withUser = username ? `${username}@${hostPart}` : hostPart;
|
||||
return port === DEFAULT_PROXY_JUMP_PORT ? withUser : `${withUser}:${port}`;
|
||||
};
|
||||
|
||||
// An empty cell means "no chain". A chain is omitted entirely when any hop
|
||||
// cannot be represented, so a shortened list is not written back as the truth.
|
||||
const formatCsvProxyJump = (host: Host, allHosts: readonly Host[]): string => {
|
||||
const ids = host.hostChain?.hostIds ?? [];
|
||||
if (ids.length === 0) return "";
|
||||
const byId = new Map(allHosts.map((item) => [item.id, item]));
|
||||
const parts: string[] = [];
|
||||
for (const id of ids) {
|
||||
const hop = byId.get(id);
|
||||
const formatted = hop ? formatCsvProxyJumpHop(hop) : null;
|
||||
if (!formatted) return "";
|
||||
parts.push(formatted);
|
||||
}
|
||||
return parts.join(",");
|
||||
};
|
||||
|
||||
const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "LocalShell"];
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
|
||||
const rows: string[][] = [header];
|
||||
let unreadableProxyCredentialCount = 0;
|
||||
|
||||
@@ -161,6 +198,9 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
}
|
||||
}
|
||||
const localShellSpec = getLocalShellSpec(host);
|
||||
// Group defaults are already on effectiveHost, same as Proxy, so a chain
|
||||
// inherited from the group survives a file that has no group-config section.
|
||||
const proxyJumpValue = formatCsvProxyJump(effectiveHost, exportableHosts);
|
||||
|
||||
rows.push([
|
||||
host.group ?? "",
|
||||
@@ -175,6 +215,7 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
encodeCsvKeyPath(keyPath),
|
||||
encodeCsvPassphrase(passphrase),
|
||||
encodeCsvProxy(proxyValue),
|
||||
encodeCsvProxyJump(proxyJumpValue),
|
||||
localShellSpec ? encodeCsvLocalShell(localShellSpec) : "",
|
||||
]);
|
||||
}
|
||||
@@ -183,9 +224,10 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
const keyPathColIdx = header.indexOf("KeyPath");
|
||||
const passphraseColIdx = header.indexOf("Passphrase");
|
||||
const proxyColIdx = header.indexOf("Proxy");
|
||||
const proxyJumpColIdx = header.indexOf("ProxyJump");
|
||||
const csv = rows.map((r, rowIdx) => r.map((c, i) => escapeCsv(
|
||||
c,
|
||||
rowIdx > 0 && (i === passwordColIdx || i === keyPathColIdx || i === passphraseColIdx || i === proxyColIdx),
|
||||
rowIdx > 0 && (i === passwordColIdx || i === keyPathColIdx || i === passphraseColIdx || i === proxyColIdx || i === proxyJumpColIdx),
|
||||
)).join(",")).join("\r\n") + "\r\n";
|
||||
return { csv, unreadableProxyCredentialCount };
|
||||
};
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
importVaultHostsFromText,
|
||||
mergeVaultImportIssues,
|
||||
resolveVaultImportKeyPassphraseConflicts,
|
||||
toVaultImportExistingHostRef,
|
||||
VAULT_IMPORT_FORMATS,
|
||||
type VaultImportFormat,
|
||||
} from '../../domain/vaultImport';
|
||||
@@ -946,7 +947,10 @@ export async function handleVaultAgentOp(
|
||||
? params.fileName.trim()
|
||||
: undefined;
|
||||
|
||||
const importResult = importVaultHostsFromText(resolvedFormat, text, { fileName });
|
||||
const importResult = importVaultHostsFromText(resolvedFormat, text, {
|
||||
fileName,
|
||||
existingHosts: deps.getHosts().map(toVaultImportExistingHostRef),
|
||||
});
|
||||
const previewHosts = importResult.hosts.map((host) => sanitizeHostForAgent(host));
|
||||
const merged = applyVaultHostImport(
|
||||
deps.getHosts(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
buildVaultHostMergeKey,
|
||||
importVaultHostsFromText,
|
||||
mergeVaultImportIssues,
|
||||
type VaultImportExistingHostRef,
|
||||
type VaultImportFormat,
|
||||
type VaultImportIssue,
|
||||
type VaultImportResult,
|
||||
@@ -24,6 +25,7 @@ interface ImportVaultHostFilesOptions {
|
||||
relativePaths?: string[];
|
||||
encoding?: VaultImportFileEncoding;
|
||||
masterPassword?: string;
|
||||
existingHosts?: readonly VaultImportExistingHostRef[];
|
||||
onProgress?: (progress: VaultImportBatchProgress) => void;
|
||||
}
|
||||
|
||||
@@ -78,6 +80,7 @@ export async function importVaultHostFiles({
|
||||
relativePaths,
|
||||
encoding,
|
||||
masterPassword,
|
||||
existingHosts,
|
||||
onProgress,
|
||||
}: ImportVaultHostFilesOptions): Promise<VaultImportResult> {
|
||||
const sourceFiles = files.map((file, index) => ({
|
||||
@@ -104,6 +107,7 @@ export async function importVaultHostFiles({
|
||||
const result = importVaultHostsFromText(format, text, {
|
||||
fileName: file.name,
|
||||
masterPassword,
|
||||
existingHosts,
|
||||
});
|
||||
const group = format === "securecrt"
|
||||
? secureCrtGroupFromFile(file, relativePath)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
VaultImportExistingHostRef,
|
||||
VaultImportFormat,
|
||||
VaultImportResult,
|
||||
} from "../../domain/vaultImport";
|
||||
@@ -22,6 +23,7 @@ export type VaultImportWorkerRequest = {
|
||||
relativePaths: string[];
|
||||
encoding: VaultImportFileEncoding | undefined;
|
||||
masterPassword?: string;
|
||||
existingHosts?: VaultImportExistingHostRef[];
|
||||
};
|
||||
|
||||
export type VaultImportWorkerResponse =
|
||||
@@ -44,6 +46,7 @@ interface ImportVaultHostsInWorkerOptions {
|
||||
files: File[];
|
||||
encoding?: VaultImportFileEncoding;
|
||||
masterPassword?: string;
|
||||
existingHosts?: readonly VaultImportExistingHostRef[];
|
||||
signal?: AbortSignal;
|
||||
createWorker?: () => VaultImportWorkerLike;
|
||||
onProgress?: (progress: VaultImportWorkerProgress) => void;
|
||||
@@ -60,6 +63,7 @@ export function importVaultHostsInWorker({
|
||||
files,
|
||||
encoding,
|
||||
masterPassword,
|
||||
existingHosts,
|
||||
signal,
|
||||
createWorker = createVaultImportWorker,
|
||||
onProgress,
|
||||
@@ -114,6 +118,7 @@ export function importVaultHostsInWorker({
|
||||
relativePaths: files.map((file) => file.webkitRelativePath),
|
||||
encoding,
|
||||
...(masterPassword ? { masterPassword } : {}),
|
||||
...(existingHosts && existingHosts.length > 0 ? { existingHosts: [...existingHosts] } : {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -23,6 +23,7 @@ workerScope.addEventListener("message", async (event: MessageEvent<VaultImportWo
|
||||
relativePaths: event.data.relativePaths,
|
||||
encoding: event.data.encoding,
|
||||
masterPassword: event.data.masterPassword,
|
||||
existingHosts: event.data.existingHosts,
|
||||
onProgress: ({ completedFiles, totalFiles, fileName }) => {
|
||||
post({
|
||||
type: "progress",
|
||||
|
||||
Reference in new issue
Block a user