mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 09:01:40 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21caffef9c | ||
|
|
94535c8c35 | ||
|
|
428022ebca | ||
|
|
f2077e7b23 | ||
|
|
61be891293 | ||
|
|
255d3d0e4e | ||
|
|
0a72824ee5 | ||
|
|
9c0b9f5ae3 | ||
|
|
6de3d9e0a9 | ||
|
|
fffe3c523d |
No files matched your search
@@ -1520,6 +1520,10 @@ export const useVaultState = () => {
|
||||
latestHosts.length,
|
||||
destination,
|
||||
storedGroups,
|
||||
{
|
||||
identities: latestIdentities.length,
|
||||
keys: latestKeys.length,
|
||||
},
|
||||
);
|
||||
const nextHosts = normalizeVaultOrder(committed.hosts.map((host) => sanitizeHost(host)));
|
||||
const nextKeys = normalizeVaultOrder(committed.keys);
|
||||
|
||||
@@ -28,6 +28,8 @@ import { sanitizeHost } from "../../domain/host";
|
||||
import {
|
||||
applyVaultImportDestination,
|
||||
applyVaultHostImport,
|
||||
buildVaultHostEndpointKey,
|
||||
buildVaultHostMergeKey,
|
||||
filterVaultImportKeyPassphrasesAgainstExisting,
|
||||
mergeVaultImportIssues,
|
||||
resolveVaultImportKeyPassphraseConflicts,
|
||||
@@ -179,6 +181,9 @@ export function useVaultImportHandlers({
|
||||
result = applyVaultImportDestination(
|
||||
result,
|
||||
options?.destination ?? { mode: "preserve" },
|
||||
// SecureCRT keeps distinct session files that share an endpoint;
|
||||
// only rewrite their group when the user picks an import location.
|
||||
{ collapseDuplicateEndpoints: format !== "securecrt" },
|
||||
);
|
||||
}
|
||||
updateProgress({ stage: "preparing", percent: 70 });
|
||||
@@ -308,15 +313,16 @@ export function useVaultImportHandlers({
|
||||
}
|
||||
}
|
||||
|
||||
const makeKey = (h: Host) =>
|
||||
`${(h.protocol ?? "ssh").toLowerCase()}|${h.hostname.toLowerCase()}|${h.port}|${(h.username ?? "").toLowerCase()}`;
|
||||
|
||||
// Managed ssh_config rematch is endpoint-only; CSV/other imports treat
|
||||
// group as part of session identity so direct vs proxy copies can coexist.
|
||||
const makeKey = isManaged ? buildVaultHostEndpointKey : buildVaultHostMergeKey;
|
||||
|
||||
const existingKeys = new Set(currentHosts.map(makeKey));
|
||||
// Filter out duplicates for both managed and non-managed imports
|
||||
let newHosts = format === "securecrt"
|
||||
? result.hosts
|
||||
: result.hosts.filter((h) => !existingKeys.has(makeKey(h)));
|
||||
|
||||
|
||||
// For managed imports, also update existing hosts to be managed
|
||||
let updatedExistingHosts: Host[] = [];
|
||||
if (isManaged) {
|
||||
|
||||
@@ -450,6 +450,321 @@ test('plugin importer destination moves only newly imported hosts into the selec
|
||||
assert.equal(targeted.addedCount, 2);
|
||||
});
|
||||
|
||||
test('plugin importer destination re-dedupes same-endpoint hosts collapsed into one group', () => {
|
||||
const existingDrafts = normalizePluginImporterRecords([{ type: 'draft', draft: {
|
||||
kind: 'host',
|
||||
value: { label: 'Existing', hostname: 'existing.test', group: 'Original' },
|
||||
} }]);
|
||||
const importedDrafts = normalizePluginImporterRecords([
|
||||
{ type: 'draft', draft: {
|
||||
kind: 'host',
|
||||
value: {
|
||||
label: 'direct',
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
group: 'lan',
|
||||
},
|
||||
} },
|
||||
{ type: 'draft', draft: {
|
||||
kind: 'host',
|
||||
value: {
|
||||
label: 'via-socks',
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
group: 'lan-proxy',
|
||||
},
|
||||
} },
|
||||
]);
|
||||
const merged = mergePluginImporterDrafts({
|
||||
hosts: existingDrafts.hosts,
|
||||
identities: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: ['Original'],
|
||||
}, importedDrafts);
|
||||
|
||||
assert.equal(merged.hosts.length, 3);
|
||||
assert.equal(merged.addedCount, 4); // 2 hosts + 2 new groups
|
||||
|
||||
const targeted = applyPluginImporterDestination(
|
||||
merged,
|
||||
existingDrafts.hosts.length,
|
||||
{ mode: 'group', group: 'Chosen' },
|
||||
['Original'],
|
||||
);
|
||||
|
||||
assert.equal(targeted.hosts.length, 2);
|
||||
assert.equal(targeted.hosts[1]?.group, 'Chosen');
|
||||
assert.deepEqual(targeted.customGroups, ['Original', 'Chosen']);
|
||||
assert.equal(targeted.duplicateCount, merged.duplicateCount + 1);
|
||||
assert.equal(targeted.addedCount, 2); // 1 retained host + Chosen group
|
||||
});
|
||||
|
||||
test('plugin importer destination keeps distinct plugin hosts that share an endpoint', () => {
|
||||
const drafts = normalizePluginImporterRecords([
|
||||
{ type: 'draft', draft: { kind: 'identity', value: {
|
||||
id: 'credential-a', label: 'Credential A', username: 'root', authMethod: 'password', password: 'first',
|
||||
} } },
|
||||
{ type: 'draft', draft: { kind: 'identity', value: {
|
||||
id: 'credential-b', label: 'Credential B', username: 'root', authMethod: 'password', password: 'second',
|
||||
} } },
|
||||
{ type: 'draft', draft: { kind: 'host', value: {
|
||||
label: 'Plugin host A',
|
||||
protocol: 'plugin:com.example.transport.connection',
|
||||
group: 'lan',
|
||||
pluginConnection: {
|
||||
providerId: 'com.example.transport.connection',
|
||||
configuration: { endpoint: 'shared', path: 'a' },
|
||||
credentialId: 'credential-a',
|
||||
},
|
||||
} } },
|
||||
{ type: 'draft', draft: { kind: 'host', value: {
|
||||
label: 'Plugin host B',
|
||||
protocol: 'plugin:com.example.transport.connection',
|
||||
group: 'lan-proxy',
|
||||
pluginConnection: {
|
||||
providerId: 'com.example.transport.connection',
|
||||
configuration: { endpoint: 'shared', path: 'b' },
|
||||
credentialId: 'credential-b',
|
||||
},
|
||||
} } },
|
||||
]);
|
||||
const merged = mergePluginImporterDrafts({
|
||||
hosts: [], identities: [], keys: [], snippets: [], customGroups: [],
|
||||
}, drafts);
|
||||
|
||||
assert.equal(merged.hosts.length, 2);
|
||||
|
||||
const targeted = applyPluginImporterDestination(
|
||||
merged,
|
||||
0,
|
||||
{ mode: 'group', group: 'Chosen' },
|
||||
);
|
||||
|
||||
assert.equal(targeted.hosts.length, 2);
|
||||
assert.deepEqual(targeted.hosts.map((host) => host.group), ['Chosen', 'Chosen']);
|
||||
assert.notEqual(
|
||||
targeted.hosts[0].pluginConnection?.configuration,
|
||||
targeted.hosts[1].pluginConnection?.configuration,
|
||||
);
|
||||
assert.equal(targeted.duplicateCount, merged.duplicateCount);
|
||||
assert.equal(targeted.addedCount, merged.addedCount - 2 + 1); // replace 2 source groups with Chosen
|
||||
});
|
||||
|
||||
test('plugin importer destination skips imports that collide with existing hosts after rewrite', () => {
|
||||
const existingDrafts = normalizePluginImporterRecords([{ type: 'draft', draft: {
|
||||
kind: 'host',
|
||||
value: {
|
||||
label: 'Existing',
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
group: 'Chosen',
|
||||
},
|
||||
} }]);
|
||||
const importedDrafts = normalizePluginImporterRecords([{ type: 'draft', draft: {
|
||||
kind: 'host',
|
||||
value: {
|
||||
label: 'Imported copy',
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
group: 'lan',
|
||||
},
|
||||
} }]);
|
||||
const merged = mergePluginImporterDrafts({
|
||||
hosts: existingDrafts.hosts,
|
||||
identities: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: ['Chosen'],
|
||||
}, importedDrafts);
|
||||
|
||||
assert.equal(merged.hosts.length, 2);
|
||||
|
||||
const targeted = applyPluginImporterDestination(
|
||||
merged,
|
||||
existingDrafts.hosts.length,
|
||||
{ mode: 'group', group: 'Chosen' },
|
||||
['Chosen'],
|
||||
);
|
||||
|
||||
assert.equal(targeted.hosts.length, 1);
|
||||
assert.equal(targeted.hosts[0]?.label, 'Existing');
|
||||
assert.equal(targeted.duplicateCount, merged.duplicateCount + 1);
|
||||
assert.equal(targeted.addedCount, 0);
|
||||
});
|
||||
|
||||
test('plugin importer destination prunes orphan credentials for skipped colliding hosts', () => {
|
||||
const existingDrafts = normalizePluginImporterRecords([{ type: 'draft', draft: {
|
||||
kind: 'host',
|
||||
value: {
|
||||
label: 'Existing',
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
group: 'Chosen',
|
||||
},
|
||||
} }]);
|
||||
const importedDrafts = normalizePluginImporterRecords([
|
||||
{ type: 'draft', draft: { kind: 'identity', value: {
|
||||
id: 'imported-identity',
|
||||
label: 'Imported identity',
|
||||
username: 'root',
|
||||
authMethod: 'password',
|
||||
password: 'secret',
|
||||
} } },
|
||||
{ type: 'draft', draft: { kind: 'key', value: {
|
||||
id: 'imported-key',
|
||||
label: 'Imported key',
|
||||
type: 'ED25519',
|
||||
privateKey: 'private-key-material',
|
||||
} } },
|
||||
{ type: 'draft', draft: { kind: 'host', value: {
|
||||
label: 'Imported copy',
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
group: 'lan',
|
||||
identityId: 'imported-identity',
|
||||
identityFileId: 'imported-key',
|
||||
} } },
|
||||
]);
|
||||
const merged = mergePluginImporterDrafts({
|
||||
hosts: existingDrafts.hosts,
|
||||
identities: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: ['Chosen'],
|
||||
}, importedDrafts);
|
||||
|
||||
assert.equal(merged.hosts.length, 2);
|
||||
assert.equal(merged.identities.length, 1);
|
||||
assert.equal(merged.keys.length, 1);
|
||||
|
||||
const targeted = applyPluginImporterDestination(
|
||||
merged,
|
||||
existingDrafts.hosts.length,
|
||||
{ mode: 'group', group: 'Chosen' },
|
||||
['Chosen'],
|
||||
{ identities: 0, keys: 0 },
|
||||
);
|
||||
|
||||
assert.equal(targeted.hosts.length, 1);
|
||||
assert.deepEqual(targeted.identities, []);
|
||||
assert.deepEqual(targeted.keys, []);
|
||||
assert.equal(targeted.addedCount, 0);
|
||||
});
|
||||
|
||||
test('plugin importer destination keeps existing credentials remapped onto skipped hosts', () => {
|
||||
const existingDrafts = normalizePluginImporterRecords([
|
||||
{ type: 'draft', draft: { kind: 'identity', value: {
|
||||
id: 'existing-identity',
|
||||
label: 'Existing identity',
|
||||
username: 'root',
|
||||
authMethod: 'password',
|
||||
password: 'vault-secret',
|
||||
} } },
|
||||
{ type: 'draft', draft: { kind: 'host', value: {
|
||||
label: 'Existing',
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
group: 'Chosen',
|
||||
identityId: 'existing-identity',
|
||||
} } },
|
||||
]);
|
||||
const existingIdentityId = existingDrafts.identities[0]?.id;
|
||||
assert.ok(existingIdentityId);
|
||||
const importedDrafts = normalizePluginImporterRecords([
|
||||
{ type: 'draft', draft: { kind: 'identity', value: {
|
||||
id: 'imported-identity',
|
||||
label: 'Duplicate identity',
|
||||
username: 'root',
|
||||
authMethod: 'password',
|
||||
password: 'vault-secret',
|
||||
} } },
|
||||
{ type: 'draft', draft: { kind: 'host', value: {
|
||||
label: 'Imported copy',
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
group: 'lan',
|
||||
identityId: 'imported-identity',
|
||||
} } },
|
||||
]);
|
||||
const merged = mergePluginImporterDrafts({
|
||||
hosts: existingDrafts.hosts,
|
||||
identities: existingDrafts.identities,
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: ['Chosen'],
|
||||
}, importedDrafts);
|
||||
|
||||
assert.equal(merged.identities.length, 1);
|
||||
assert.equal(merged.identities[0]?.id, existingIdentityId);
|
||||
assert.equal(merged.duplicateCount >= 1, true);
|
||||
|
||||
const targeted = applyPluginImporterDestination(
|
||||
merged,
|
||||
existingDrafts.hosts.length,
|
||||
{ mode: 'group', group: 'Chosen' },
|
||||
['Chosen'],
|
||||
{ identities: existingDrafts.identities.length, keys: 0 },
|
||||
);
|
||||
|
||||
assert.equal(targeted.hosts.length, 1);
|
||||
assert.equal(targeted.identities.length, 1);
|
||||
assert.equal(targeted.identities[0]?.id, existingIdentityId);
|
||||
});
|
||||
|
||||
test('plugin importer destination merges referenced credentials when collapsing imports', () => {
|
||||
const drafts = normalizePluginImporterRecords([
|
||||
{ type: 'draft', draft: { kind: 'identity', value: {
|
||||
id: 'imported-identity',
|
||||
label: 'Imported identity',
|
||||
username: 'root',
|
||||
authMethod: 'password',
|
||||
password: 'secret',
|
||||
} } },
|
||||
{ type: 'draft', draft: { kind: 'host', value: {
|
||||
label: 'bare',
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
group: 'lan',
|
||||
} } },
|
||||
{ type: 'draft', draft: { kind: 'host', value: {
|
||||
label: 'with-identity',
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
group: 'lan-proxy',
|
||||
identityId: 'imported-identity',
|
||||
} } },
|
||||
]);
|
||||
const merged = mergePluginImporterDrafts({
|
||||
hosts: [], identities: [], keys: [], snippets: [], customGroups: [],
|
||||
}, drafts);
|
||||
|
||||
assert.equal(merged.hosts.length, 2);
|
||||
assert.equal(merged.identities.length, 1);
|
||||
|
||||
const targeted = applyPluginImporterDestination(
|
||||
merged,
|
||||
0,
|
||||
{ mode: 'group', group: 'Chosen' },
|
||||
[],
|
||||
{ identities: 0, keys: 0 },
|
||||
);
|
||||
|
||||
assert.equal(targeted.hosts.length, 1);
|
||||
assert.equal(targeted.hosts[0]?.identityId, merged.identities[0]?.id);
|
||||
assert.equal(targeted.identities.length, 1);
|
||||
});
|
||||
|
||||
test('plugin importer does not count a host-owned destination group as newly added', () => {
|
||||
const existingDrafts = normalizePluginImporterRecords([{ type: 'draft', draft: {
|
||||
kind: 'host',
|
||||
|
||||
@@ -474,11 +474,29 @@ export function applyPluginImporterDestination(
|
||||
existingHostCount: number,
|
||||
destination?: VaultImportDestination,
|
||||
existingCustomGroups: ReadonlyArray<string> = [],
|
||||
existingCounts: {
|
||||
identities?: number;
|
||||
keys?: number;
|
||||
} = {},
|
||||
): PluginImporterMergeResult {
|
||||
if (!destination || destination.mode === 'preserve') return merged;
|
||||
const splitAt = Math.max(0, Math.min(existingHostCount, merged.hosts.length));
|
||||
const existingHosts = merged.hosts.slice(0, splitAt);
|
||||
const importedHosts = merged.hosts.slice(splitAt);
|
||||
const existingIdentityCount = Math.max(
|
||||
0,
|
||||
Math.min(existingCounts.identities ?? 0, merged.identities.length),
|
||||
);
|
||||
const existingKeyCount = Math.max(
|
||||
0,
|
||||
Math.min(existingCounts.keys ?? 0, merged.keys.length),
|
||||
);
|
||||
const existingIdentityIds = new Set(
|
||||
merged.identities.slice(0, existingIdentityCount).map((identity) => identity.id),
|
||||
);
|
||||
const existingKeyIds = new Set(
|
||||
merged.keys.slice(0, existingKeyCount).map((key) => key.id),
|
||||
);
|
||||
const targeted = applyVaultImportDestination({
|
||||
hosts: importedHosts,
|
||||
groups: [],
|
||||
@@ -489,7 +507,63 @@ export function applyPluginImporterDestination(
|
||||
skipped: 0,
|
||||
duplicates: 0,
|
||||
},
|
||||
}, destination);
|
||||
}, destination, {
|
||||
// Plugin hosts that differ by configuration/credentials must survive a
|
||||
// shared destination group; classic vault hosts still collapse by endpoint.
|
||||
isCollapsible: (host) => !host.pluginConnection,
|
||||
});
|
||||
// Destination rewrite can make an imported vault host collide with an existing
|
||||
// one (same endpoint, now same group). Re-check like the CSV import path.
|
||||
const existingFingerprints = new Set(existingHosts.map(hostFingerprint));
|
||||
const retainedImported: Host[] = [];
|
||||
for (const host of targeted.hosts) {
|
||||
const fingerprint = hostFingerprint(host);
|
||||
if (existingFingerprints.has(fingerprint)) continue;
|
||||
existingFingerprints.add(fingerprint);
|
||||
retainedImported.push(host);
|
||||
}
|
||||
const retainedImportedIds = new Set(retainedImported.map((host) => host.id));
|
||||
const droppedImported = importedHosts.filter((host) => !retainedImportedIds.has(host.id));
|
||||
const collectHostCredentialIds = (hosts: ReadonlyArray<Host>): Set<string> => {
|
||||
const ids = new Set<string>();
|
||||
for (const host of hosts) {
|
||||
if (host.identityId) ids.add(host.identityId);
|
||||
if (host.telnetIdentityId) ids.add(host.telnetIdentityId);
|
||||
if (host.identityFileId) ids.add(host.identityFileId);
|
||||
const pluginCredentialId = host.pluginConnection?.credentialId;
|
||||
if (pluginCredentialId) ids.add(pluginCredentialId);
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
const keptCredentialIds = collectHostCredentialIds([...existingHosts, ...retainedImported]);
|
||||
const droppedOnlyCredentialIds = new Set(
|
||||
[...collectHostCredentialIds(droppedImported)].filter((id) => !keptCredentialIds.has(id)),
|
||||
);
|
||||
// Only prune credentials introduced by this import. Remapped duplicates that
|
||||
// point at pre-existing vault identities/keys must stay in the vault.
|
||||
const nextIdentities = merged.identities.filter((identity) => (
|
||||
existingIdentityIds.has(identity.id)
|
||||
|| !droppedOnlyCredentialIds.has(identity.id)
|
||||
));
|
||||
const keptIdentityKeyIds = new Set(
|
||||
nextIdentities.flatMap((identity) => (identity.keyId ? [identity.keyId] : [])),
|
||||
);
|
||||
const prunedIdentityKeyIds = new Set(
|
||||
merged.identities
|
||||
.filter((identity) => (
|
||||
!existingIdentityIds.has(identity.id)
|
||||
&& droppedOnlyCredentialIds.has(identity.id)
|
||||
))
|
||||
.flatMap((identity) => (identity.keyId ? [identity.keyId] : [])),
|
||||
);
|
||||
const nextKeys = merged.keys.filter((key) => {
|
||||
if (existingKeyIds.has(key.id)) return true;
|
||||
if (keptCredentialIds.has(key.id) || keptIdentityKeyIds.has(key.id)) return true;
|
||||
if (droppedOnlyCredentialIds.has(key.id) || prunedIdentityKeyIds.has(key.id)) return false;
|
||||
return true;
|
||||
});
|
||||
const prunedIdentityCount = merged.identities.length - nextIdentities.length;
|
||||
const prunedKeyCount = merged.keys.length - nextKeys.length;
|
||||
const existingGroupSet = new Set([
|
||||
...existingCustomGroups,
|
||||
...existingHosts.flatMap((host) => host.group ? [host.group] : []),
|
||||
@@ -501,11 +575,22 @@ export function applyPluginImporterDestination(
|
||||
const nextAddedGroupCount = customGroups.filter(
|
||||
(group) => !existingGroupSet.has(group),
|
||||
).length;
|
||||
// Destination rewriting can collapse same-endpoint hosts into one group;
|
||||
// keep added/duplicate counts aligned with the hosts actually retained.
|
||||
const collapsedHostCount = Math.max(0, importedHosts.length - retainedImported.length);
|
||||
return {
|
||||
...merged,
|
||||
hosts: [...existingHosts, ...targeted.hosts],
|
||||
hosts: [...existingHosts, ...retainedImported],
|
||||
identities: nextIdentities,
|
||||
keys: nextKeys,
|
||||
customGroups,
|
||||
addedCount: merged.addedCount - previousAddedGroupCount + nextAddedGroupCount,
|
||||
duplicateCount: merged.duplicateCount + collapsedHostCount,
|
||||
addedCount: merged.addedCount
|
||||
- previousAddedGroupCount
|
||||
+ nextAddedGroupCount
|
||||
- collapsedHostCount
|
||||
- prunedIdentityCount
|
||||
- prunedKeyCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,40 @@ import {
|
||||
applyVaultHostDelete,
|
||||
applyVaultHostCreates,
|
||||
applyVaultHostUpdate,
|
||||
buildVaultHostEndpointKey,
|
||||
buildVaultHostFromDraft,
|
||||
buildVaultHostMergeKey,
|
||||
buildVaultHostsFromDrafts,
|
||||
parseVaultHostDraftsInput,
|
||||
type VaultHostDraft,
|
||||
type VaultHostUpdatePatch,
|
||||
} from './vaultHostCreate.ts';
|
||||
|
||||
test('buildVaultHostMergeKey treats group as part of session identity', () => {
|
||||
const base = {
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
protocol: 'ssh' as const,
|
||||
};
|
||||
assert.notEqual(
|
||||
buildVaultHostMergeKey({ ...base, group: 'lan' }),
|
||||
buildVaultHostMergeKey({ ...base, group: 'lan-proxy' }),
|
||||
);
|
||||
assert.equal(
|
||||
buildVaultHostMergeKey({ ...base, group: 'lan' }),
|
||||
buildVaultHostMergeKey({ ...base, group: 'lan' }),
|
||||
);
|
||||
assert.notEqual(
|
||||
buildVaultHostMergeKey({ ...base, group: 'Prod' }),
|
||||
buildVaultHostMergeKey({ ...base, group: 'prod' }),
|
||||
);
|
||||
assert.equal(
|
||||
buildVaultHostEndpointKey({ ...base, group: 'lan' }),
|
||||
buildVaultHostEndpointKey({ ...base, group: 'lan-proxy' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('buildVaultHostFromDraft maps minimal unstructured fields to a vault host', () => {
|
||||
const built = buildVaultHostFromDraft({
|
||||
hostname: '192.168.1.10',
|
||||
|
||||
@@ -169,11 +169,25 @@ const parseKeyPath = (draft: VaultHostDraft): string | undefined => {
|
||||
return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined;
|
||||
};
|
||||
|
||||
export const buildVaultHostMergeKey = (
|
||||
/** Endpoint-only identity (protocol/host/port/user). Used for managed ssh_config matching. */
|
||||
export const buildVaultHostEndpointKey = (
|
||||
host: Pick<Host, 'hostname' | 'port' | 'username' | 'protocol'>,
|
||||
): string =>
|
||||
`${(host.protocol ?? 'ssh').toLowerCase()}|${host.hostname.toLowerCase()}|${host.port}|${(host.username ?? '').toLowerCase()}`;
|
||||
|
||||
/**
|
||||
* Session identity for vault import/create dedupe.
|
||||
* Same endpoint in a different group is a distinct connection (e.g. direct vs proxy copy).
|
||||
*/
|
||||
export const buildVaultHostMergeKey = (
|
||||
host: Pick<Host, 'hostname' | 'port' | 'username' | 'protocol' | 'group'>,
|
||||
): string => {
|
||||
// Keep normalized group spelling (do not case-fold). Vault group paths are
|
||||
// compared exactly elsewhere, so Prod vs prod are distinct sessions.
|
||||
const group = normalizeGroupPath(host.group) ?? '';
|
||||
return `${buildVaultHostEndpointKey(host)}|${group}`;
|
||||
};
|
||||
|
||||
export function buildVaultHostFromDraft(
|
||||
draft: VaultHostDraft,
|
||||
): { ok: true; host: Host } | { ok: false; error: string } {
|
||||
|
||||
@@ -185,6 +185,185 @@ test("vault import can place every imported host into one selected group", () =>
|
||||
assert.deepEqual(imported.groups, ["Production/Web", "Production/DB"]);
|
||||
});
|
||||
|
||||
test("applyVaultImportDestination re-dedupes same-endpoint hosts collapsed into one group", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Port,Username",
|
||||
"lan,direct,10.10.10.10,22,root",
|
||||
"lan-proxy,via-socks,10.10.10.10,22,root",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(imported.hosts.length, 2);
|
||||
|
||||
const targeted = applyVaultImportDestination(imported, {
|
||||
mode: "group",
|
||||
group: "Imported/July",
|
||||
});
|
||||
|
||||
assert.equal(targeted.hosts.length, 1);
|
||||
assert.equal(targeted.hosts[0]?.group, "Imported/July");
|
||||
assert.equal(targeted.stats.duplicates, 1);
|
||||
assert.equal(targeted.stats.imported, 1);
|
||||
assert.deepEqual(targeted.groups, ["Imported/July"]);
|
||||
|
||||
const merged = applyVaultHostImport([], [], targeted);
|
||||
assert.equal(merged.addedCount, 1);
|
||||
assert.equal(merged.hosts.length, 1);
|
||||
});
|
||||
|
||||
test("applyVaultImportDestination remaps a shared key passphrase onto the retained host", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Port,Username,KeyPath,Passphrase",
|
||||
"lan,direct,10.10.10.10,22,root,~/.ssh/id_ed25519,",
|
||||
"lan-proxy,via-socks,10.10.10.10,22,root,~/.ssh/id_ed25519,secret",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(imported.hosts.length, 2);
|
||||
|
||||
const targeted = applyVaultImportDestination(imported, {
|
||||
mode: "group",
|
||||
group: "Imported/July",
|
||||
});
|
||||
|
||||
assert.equal(targeted.hosts.length, 1);
|
||||
assert.deepEqual(targeted.hosts[0]?.identityFilePaths, ["~/.ssh/id_ed25519"]);
|
||||
assert.deepEqual(targeted.keyPassphrases, [{
|
||||
hostId: targeted.hosts[0]?.id,
|
||||
keyPath: "~/.ssh/id_ed25519",
|
||||
passphrase: "secret",
|
||||
}]);
|
||||
});
|
||||
|
||||
test("applyVaultImportDestination does not attach a passphrase for a non-retained key", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Port,Username,KeyPath,Passphrase",
|
||||
"lan,direct,10.10.10.10,22,root,~/.ssh/id_first,first",
|
||||
"lan-proxy,via-socks,10.10.10.10,22,root,~/.ssh/id_second,second",
|
||||
].join("\n"));
|
||||
|
||||
const targeted = applyVaultImportDestination(imported, {
|
||||
mode: "group",
|
||||
group: "Imported/July",
|
||||
});
|
||||
|
||||
assert.equal(targeted.hosts.length, 1);
|
||||
assert.deepEqual(targeted.hosts[0]?.identityFilePaths, ["~/.ssh/id_first"]);
|
||||
assert.deepEqual(targeted.keyPassphrases, [{
|
||||
hostId: targeted.hosts[0]?.id,
|
||||
keyPath: "~/.ssh/id_first",
|
||||
passphrase: "first",
|
||||
}]);
|
||||
});
|
||||
|
||||
test("applyVaultImportDestination can keep same-endpoint hosts when collapse is disabled", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Port,Username",
|
||||
"Prod,web-a,shared.example.com,22,root",
|
||||
"Staging,web-b,shared.example.com,22,root",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(imported.hosts.length, 2);
|
||||
|
||||
const targeted = applyVaultImportDestination(
|
||||
imported,
|
||||
{ mode: "group", group: "Imported/SecureCRT" },
|
||||
{ collapseDuplicateEndpoints: false },
|
||||
);
|
||||
|
||||
assert.equal(targeted.hosts.length, 2);
|
||||
assert.deepEqual(targeted.hosts.map((host) => host.group), [
|
||||
"Imported/SecureCRT",
|
||||
"Imported/SecureCRT",
|
||||
]);
|
||||
assert.equal(targeted.stats.duplicates, 0);
|
||||
assert.equal(targeted.stats.imported, 2);
|
||||
});
|
||||
|
||||
test("applyVaultImportDestination can skip collapse for selected hosts", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Port,Username",
|
||||
"lan,direct,10.10.10.10,22,root",
|
||||
"lan-proxy,via-socks,10.10.10.10,22,root",
|
||||
].join("\n"));
|
||||
const withPluginFlag = {
|
||||
...imported,
|
||||
hosts: imported.hosts.map((host, index) => (
|
||||
index === 0
|
||||
? host
|
||||
: {
|
||||
...host,
|
||||
pluginConnection: {
|
||||
providerId: "com.example.transport.connection",
|
||||
configuration: { endpoint: "via-socks" },
|
||||
},
|
||||
}
|
||||
)),
|
||||
};
|
||||
|
||||
const targeted = applyVaultImportDestination(
|
||||
withPluginFlag,
|
||||
{ mode: "group", group: "Imported/Plugin" },
|
||||
{ isCollapsible: (host) => !host.pluginConnection },
|
||||
);
|
||||
|
||||
assert.equal(targeted.hosts.length, 2);
|
||||
assert.deepEqual(targeted.hosts.map((host) => host.group), [
|
||||
"Imported/Plugin",
|
||||
"Imported/Plugin",
|
||||
]);
|
||||
});
|
||||
|
||||
test("applyVaultImportDestination merges referenced credentials onto the retained host", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Port,Username",
|
||||
"lan,direct,10.10.10.10,22,root",
|
||||
"lan-proxy,via-socks,10.10.10.10,22,root",
|
||||
].join("\n"));
|
||||
const [first, second] = imported.hosts;
|
||||
assert.ok(first && second);
|
||||
const withRefs = {
|
||||
...imported,
|
||||
hosts: [
|
||||
{ ...first, identityId: undefined, identityFileId: undefined },
|
||||
{ ...second, identityId: "identity-1", identityFileId: "key-1" },
|
||||
],
|
||||
};
|
||||
|
||||
const targeted = applyVaultImportDestination(withRefs, {
|
||||
mode: "group",
|
||||
group: "Imported/July",
|
||||
});
|
||||
|
||||
assert.equal(targeted.hosts.length, 1);
|
||||
assert.equal(targeted.hosts[0]?.identityId, "identity-1");
|
||||
assert.equal(targeted.hosts[0]?.identityFileId, "key-1");
|
||||
});
|
||||
|
||||
test("applyVaultImportDestination does not override retained key auth with identity refs", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Port,Username,KeyPath",
|
||||
"lan,direct,10.10.10.10,22,root,~/.ssh/id_ed25519",
|
||||
"lan-proxy,via-socks,10.10.10.10,22,root,",
|
||||
].join("\n"));
|
||||
const [first, second] = imported.hosts;
|
||||
assert.ok(first && second);
|
||||
const withRefs = {
|
||||
...imported,
|
||||
hosts: [
|
||||
first,
|
||||
{ ...second, identityId: "identity-1" },
|
||||
],
|
||||
};
|
||||
|
||||
const targeted = applyVaultImportDestination(withRefs, {
|
||||
mode: "group",
|
||||
group: "Imported/July",
|
||||
});
|
||||
|
||||
assert.equal(targeted.hosts.length, 1);
|
||||
assert.deepEqual(targeted.hosts[0]?.identityFilePaths, ["~/.ssh/id_ed25519"]);
|
||||
assert.equal(targeted.hosts[0]?.identityId, undefined);
|
||||
});
|
||||
|
||||
test("CSV import keeps working when KeyPath and Passphrase columns are absent", () => {
|
||||
const result = importVaultHostsFromText(
|
||||
"csv",
|
||||
@@ -614,6 +793,69 @@ test("applyVaultHostImport skips duplicates by default", () => {
|
||||
assert.equal(merged.hosts.length, 2);
|
||||
});
|
||||
|
||||
test("CSV import keeps same-endpoint rows that use different groups", () => {
|
||||
const result = importVaultHostsFromText(
|
||||
"csv",
|
||||
[
|
||||
"Groups,Label,Hostname,Port,Username",
|
||||
"lan,direct,10.10.10.10,22,root",
|
||||
"lan-proxy,via-socks,10.10.10.10,22,root",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
assert.equal(result.hosts.length, 2);
|
||||
assert.equal(result.stats.duplicates, 0);
|
||||
assert.deepEqual(
|
||||
result.hosts.map((host) => host.group).sort(),
|
||||
["lan", "lan-proxy"],
|
||||
);
|
||||
});
|
||||
|
||||
test("applyVaultHostImport keeps same-endpoint hosts when the group differs", () => {
|
||||
const existing: Host = {
|
||||
id: "existing-1",
|
||||
label: "direct",
|
||||
hostname: "10.10.10.10",
|
||||
username: "root",
|
||||
port: 22,
|
||||
group: "lan",
|
||||
};
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Port,Username",
|
||||
"lan-proxy,via-socks,10.10.10.10,22,root",
|
||||
].join("\n"));
|
||||
const targeted = applyVaultImportDestination(imported, {
|
||||
mode: "group",
|
||||
group: "lan-proxy",
|
||||
});
|
||||
|
||||
const merged = applyVaultHostImport([existing], ["lan"], targeted);
|
||||
assert.equal(merged.addedCount, 1);
|
||||
assert.equal(merged.skippedExistingCount, 0);
|
||||
assert.equal(merged.hosts.length, 2);
|
||||
assert.equal(merged.addedHosts[0]?.group, "lan-proxy");
|
||||
});
|
||||
|
||||
test("applyVaultHostImport still skips same-endpoint hosts in the same group", () => {
|
||||
const existing: Host = {
|
||||
id: "existing-1",
|
||||
label: "direct",
|
||||
hostname: "10.10.10.10",
|
||||
username: "root",
|
||||
port: 22,
|
||||
group: "lan",
|
||||
};
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Port,Username",
|
||||
"lan,copy,10.10.10.10,22,root",
|
||||
].join("\n"));
|
||||
|
||||
const merged = applyVaultHostImport([existing], ["lan"], imported);
|
||||
assert.equal(merged.addedCount, 0);
|
||||
assert.equal(merged.skippedExistingCount, 1);
|
||||
assert.equal(merged.hosts.length, 1);
|
||||
});
|
||||
|
||||
test("applyVaultHostImport can preserve distinct sessions with the same endpoint", () => {
|
||||
const existing: Host = {
|
||||
id: "existing-1",
|
||||
|
||||
+119
-4
@@ -9,7 +9,10 @@ import {
|
||||
type VaultHostDraftProtocol,
|
||||
} from "./vaultHostCreate";
|
||||
|
||||
export { buildVaultHostMergeKey } from "./vaultHostCreate";
|
||||
export {
|
||||
buildVaultHostEndpointKey,
|
||||
buildVaultHostMergeKey,
|
||||
} from "./vaultHostCreate";
|
||||
import { parseQuickConnectInput } from "./quickConnect";
|
||||
import { findExactHeaderIndex, findHeaderIndex, parseCsv } from "./vaultImport/csvUtils";
|
||||
import { decodeCsvKeyPath, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
|
||||
@@ -136,6 +139,20 @@ 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[] {
|
||||
@@ -162,14 +179,89 @@ const normalizeGroupPath = (raw: string | undefined): string | undefined => {
|
||||
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: result.hosts.map((host) => ({ ...host, group })),
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -227,15 +319,24 @@ const createHost = (input: {
|
||||
return built.host;
|
||||
};
|
||||
|
||||
const dedupeHosts = (hosts: Host[]): { hosts: Host[]; duplicates: number } => {
|
||||
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++;
|
||||
@@ -248,13 +349,27 @@ const dedupeHosts = (hosts: Host[]): { hosts: Host[]; duplicates: number } => {
|
||||
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: Array.from(seen.values()), duplicates };
|
||||
return { hosts: retained, duplicates };
|
||||
};
|
||||
|
||||
const uniq = (items: string[]) => Array.from(new Set(items.filter(Boolean)));
|
||||
|
||||
@@ -87,6 +87,38 @@ test("SecureCRT keeps separate session files that point to the same endpoint", a
|
||||
assert.deepEqual(result.hosts.map((host) => host.group), ["Prod", "Staging"]);
|
||||
});
|
||||
|
||||
test("SecureCRT destination group keeps same-endpoint session files", async () => {
|
||||
const { applyVaultImportDestination } = await import("../../domain/vaultImport");
|
||||
const session = [
|
||||
'S:"Hostname"=shared.example.com',
|
||||
'S:"Username"=root',
|
||||
'S:"Protocol Name"=SSH2',
|
||||
].join("\n");
|
||||
const imported = await importVaultHostFiles({
|
||||
format: "securecrt",
|
||||
files: [
|
||||
new File([session], "web.ini"),
|
||||
new File([session], "web.ini"),
|
||||
],
|
||||
relativePaths: [
|
||||
"Sessions/Prod/web.ini",
|
||||
"Sessions/Staging/web.ini",
|
||||
],
|
||||
});
|
||||
|
||||
const targeted = applyVaultImportDestination(
|
||||
imported,
|
||||
{ mode: "group", group: "Imported/SecureCRT" },
|
||||
{ collapseDuplicateEndpoints: false },
|
||||
);
|
||||
|
||||
assert.equal(targeted.hosts.length, 2);
|
||||
assert.deepEqual(targeted.hosts.map((host) => host.group), [
|
||||
"Imported/SecureCRT",
|
||||
"Imported/SecureCRT",
|
||||
]);
|
||||
});
|
||||
|
||||
test("SecureCRT batch import does not count an unsupported session twice", async () => {
|
||||
const result = await importVaultHostFiles({
|
||||
format: "securecrt",
|
||||
|
||||
Reference in new issue
Block a user