mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07acada911 | ||
|
|
60a6fff2fe | ||
|
|
43937706f1 |
No files matched your search
@@ -1106,6 +1106,7 @@ Highlight the focused split pane:
|
||||
'vault.hosts.export.toast.success': 'Exported {count} hosts to CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': 'Exported {count} hosts to CSV ({skipped} unsupported hosts skipped)',
|
||||
'vault.hosts.export.toast.passphrasesSkipped': '{count} saved key passphrases could not be read and were left blank',
|
||||
'vault.hosts.export.toast.proxyCredentialsSkipped': '{count} saved proxy passwords could not be read and were left blank',
|
||||
'vault.hosts.export.toast.noHosts': 'No hosts to export',
|
||||
'vault.hosts.allHosts': 'All hosts',
|
||||
'vault.hosts.pinned': 'Pinned',
|
||||
|
||||
@@ -1064,6 +1064,7 @@ Resalta el panel dividido enfocado:
|
||||
'vault.hosts.export.toast.success': 'Se exportaron {count} hosts a CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': 'Se exportaron {count} hosts a CSV (se omitieron {skipped} hosts no compatibles)',
|
||||
'vault.hosts.export.toast.passphrasesSkipped': 'No se pudieron leer {count} frases de contraseña de claves guardadas y se dejaron en blanco',
|
||||
'vault.hosts.export.toast.proxyCredentialsSkipped': 'No se pudieron leer {count} contraseñas de proxy guardadas y se dejaron en blanco',
|
||||
'vault.hosts.export.toast.noHosts': 'No hay hosts para exportar',
|
||||
'vault.hosts.allHosts': 'Todos los hosts',
|
||||
'vault.hosts.pinned': 'Fijados',
|
||||
|
||||
@@ -108,6 +108,7 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.hosts.export.toast.success': 'Экспортировано {count} хостов в CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': 'Экспортировано {count} хостов в CSV ({skipped} неподдерживаемых хостов пропущено)',
|
||||
'vault.hosts.export.toast.passphrasesSkipped': 'Не удалось прочитать {count} сохранённых парольных фраз ключей; они оставлены пустыми',
|
||||
'vault.hosts.export.toast.proxyCredentialsSkipped': 'Не удалось прочитать {count} сохранённых паролей прокси; они оставлены пустыми',
|
||||
'vault.hosts.export.toast.noHosts': 'Нет хостов для экспорта',
|
||||
'vault.hosts.allHosts': 'Все хосты',
|
||||
'vault.hosts.pinned': 'Закреплённые',
|
||||
|
||||
@@ -736,6 +736,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.hosts.export.toast.success': '已导出 {count} 个主机到 CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': '已导出 {count} 个主机到 CSV(跳过 {skipped} 个不支持的主机)',
|
||||
'vault.hosts.export.toast.passphrasesSkipped': '有 {count} 个已保存的密钥口令无法读取,导出时已留空',
|
||||
'vault.hosts.export.toast.proxyCredentialsSkipped': '有 {count} 个已保存的代理密码无法读取,导出时已留空',
|
||||
'vault.hosts.export.toast.noHosts': '没有主机可导出',
|
||||
'vault.hosts.allHosts': '全部主机',
|
||||
'vault.hosts.pinned': '已置顶',
|
||||
|
||||
@@ -738,6 +738,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.hosts.export.toast.success': '已匯出 {count} 個主機到 CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': '已匯出 {count} 個主機到 CSV(跳過 {skipped} 個不支援的主機)',
|
||||
'vault.hosts.export.toast.passphrasesSkipped': '有 {count} 個已儲存的金鑰密碼無法讀取,匯出時已留空',
|
||||
'vault.hosts.export.toast.proxyCredentialsSkipped': '有 {count} 個已儲存的代理密碼無法讀取,匯出時已留空',
|
||||
'vault.hosts.export.toast.noHosts': '沒有主機可匯出',
|
||||
'vault.hosts.allHosts': '全部主機',
|
||||
'vault.hosts.pinned': '已置頂',
|
||||
|
||||
@@ -697,12 +697,15 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
keyPassphrases,
|
||||
unreadablePassphraseCount,
|
||||
} = await buildVaultCsvCredentialOptions(hosts, keys);
|
||||
const { csv, exportedCount, skippedCount } = exportHostsToCsvWithStats(
|
||||
const { csv, exportedCount, skippedCount, unreadableProxyCredentialCount } = exportHostsToCsvWithStats(
|
||||
hosts,
|
||||
{
|
||||
keyPassphrases,
|
||||
keyPassphrasesById,
|
||||
keyPathsById,
|
||||
proxyProfiles,
|
||||
identities,
|
||||
groupConfigs,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -728,6 +731,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (unreadableProxyCredentialCount > 0) {
|
||||
toast.warning(
|
||||
t("vault.hosts.export.toast.proxyCredentialsSkipped", {
|
||||
count: unreadableProxyCredentialCount,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (skippedCount > 0) {
|
||||
toast.warning(
|
||||
t("vault.hosts.export.toast.successWithSkipped", {
|
||||
@@ -740,7 +750,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
t("vault.hosts.export.toast.success", { count: exportedCount }),
|
||||
);
|
||||
}
|
||||
}, [hosts, keys, t]);
|
||||
}, [hosts, keys, proxyProfiles, identities, groupConfigs, t]);
|
||||
|
||||
// Copy hostname/IP for cross-host paste without opening the editor
|
||||
const handleCopyHostname = useCallback(
|
||||
|
||||
@@ -16,6 +16,7 @@ export {
|
||||
import { parseQuickConnectInput } from "./quickConnect";
|
||||
import { findExactHeaderIndex, findHeaderIndex, parseCsv } from "./vaultImport/csvUtils";
|
||||
import { decodeCsvKeyPath, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
|
||||
import { parseCsvProxy } from "./vaultImport/csvProxy";
|
||||
import { attachMobaXtermPasswords } from "./vaultImport/mobaXtermPasswords";
|
||||
import { decodeFinalShellPassword } from "./finalShellPassword";
|
||||
export {
|
||||
@@ -444,6 +445,7 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
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 proxyIdx = findExactHeaderIndex(header, ["proxy", "proxyserver", "proxy server"]);
|
||||
const passphraseIdx = keyPathIdx >= 0 ? explicitPassphraseIdx : -1;
|
||||
const exactPasswordIdx = findExactHeaderIndex(header, ["password", "pass", "passwd"]);
|
||||
const fuzzyNamedPasswordIdx = findHeaderIndex(header, ["password", "passwd"]);
|
||||
@@ -526,6 +528,14 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
const passphrase = decodedPassphrase && !isEncryptedCredentialPlaceholder(decodedPassphrase)
|
||||
? decodedPassphrase
|
||||
: undefined;
|
||||
const proxyRaw = (proxyIdx >= 0 ? row[proxyIdx] : undefined)?.trim();
|
||||
const proxyConfig = proxyRaw ? parseCsvProxy(proxyRaw) : undefined;
|
||||
if (proxyRaw && !proxyConfig) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV row ${i + 2}: Proxy was ignored because it is not a valid http://, socks5://, or command:// value.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (decodedPassphrase && isEncryptedCredentialPlaceholder(decodedPassphrase)) {
|
||||
issues.push({
|
||||
@@ -553,6 +563,7 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
tags,
|
||||
notes,
|
||||
});
|
||||
if (proxyConfig) host.proxyConfig = proxyConfig;
|
||||
parsedHosts.push(host);
|
||||
if (keyPath && passphrase) {
|
||||
const keyPathKey = normalizeKeyPathKey(keyPath);
|
||||
|
||||
@@ -1,6 +1,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 encodeMarkedField = (value: string, marker: string): string => (
|
||||
FORMULA_PREFIX.test(value) || value.startsWith(marker)
|
||||
@@ -32,3 +33,11 @@ export const encodeCsvPassphrase = (value: string): string => (
|
||||
export const decodeCsvPassphrase = (value: string): string => (
|
||||
decodeMarkedField(value, PASSPHRASE_MARKER)
|
||||
);
|
||||
|
||||
export const encodeCsvProxy = (value: string): string => (
|
||||
encodeMarkedField(value, PROXY_MARKER)
|
||||
);
|
||||
|
||||
export const decodeCsvProxy = (value: string): string => (
|
||||
decodeMarkedField(value, PROXY_MARKER)
|
||||
);
|
||||
@@ -2,11 +2,14 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { importVaultHostsFromText } from "../vaultImport.ts";
|
||||
import { exportHostsToCsvWithStats } from "./csvExport.ts";
|
||||
import { exportHostsToCsvWithStats, getVaultCsvTemplate } from "./csvExport.ts";
|
||||
import type { Host } from "../models.ts";
|
||||
|
||||
const hostDefaults = { username: "root", tags: [], os: "linux" as const };
|
||||
|
||||
test("CSV exports include a UTF-8 BOM and preserve Chinese text when imported again", () => {
|
||||
const host: Host = {
|
||||
...hostDefaults,
|
||||
id: "host-1",
|
||||
label: "中文服务器",
|
||||
hostname: "10.0.0.1",
|
||||
@@ -26,6 +29,7 @@ test("CSV exports include a UTF-8 BOM and preserve Chinese text when imported ag
|
||||
|
||||
test("CSV round-trips local key authentication and its saved passphrase", () => {
|
||||
const host: Host = {
|
||||
...hostDefaults,
|
||||
id: "host-key",
|
||||
label: "Key host",
|
||||
hostname: "key.example.com",
|
||||
@@ -53,6 +57,7 @@ test("CSV round-trips local key authentication and its saved passphrase", () =>
|
||||
|
||||
test("CSV round-trips a referenced Keychain file path and saved passphrase", () => {
|
||||
const host: Host = {
|
||||
...hostDefaults,
|
||||
id: "host-reference-key",
|
||||
label: "Reference key host",
|
||||
hostname: "reference.example.com",
|
||||
@@ -81,6 +86,7 @@ test("CSV round-trips a referenced Keychain file path and saved passphrase", ()
|
||||
|
||||
test("CSV never falls back to path storage for a referenced key", () => {
|
||||
const host: Host = {
|
||||
...hostDefaults,
|
||||
id: "host-reference-key",
|
||||
label: "Reference key host",
|
||||
hostname: "reference.example.com",
|
||||
@@ -108,6 +114,7 @@ test("CSV reversibly guards key paths that spreadsheets treat as formulas", () =
|
||||
"'-literal-key",
|
||||
"__netcatty_csv_keypath_v1__:literal",
|
||||
].map((keyPath, index) => ({
|
||||
...hostDefaults,
|
||||
id: `host-${index}`,
|
||||
label: `Host ${index}`,
|
||||
hostname: `host-${index}.example.com`,
|
||||
@@ -129,6 +136,7 @@ test("CSV reversibly guards key paths that spreadsheets treat as formulas", () =
|
||||
|
||||
test("CSV export never writes credentials from skipped serial hosts", () => {
|
||||
const serialHost: Host = {
|
||||
...hostDefaults,
|
||||
id: "serial-with-stale-key",
|
||||
label: "Serial with stale key",
|
||||
hostname: "ttyUSB0",
|
||||
@@ -138,6 +146,7 @@ test("CSV export never writes credentials from skipped serial hosts", () => {
|
||||
authMethod: "key",
|
||||
};
|
||||
const sshHost: Host = {
|
||||
...hostDefaults,
|
||||
id: "ssh-host",
|
||||
label: "SSH host",
|
||||
hostname: "ssh.example.com",
|
||||
@@ -159,6 +168,7 @@ test("CSV export never writes credentials from skipped serial hosts", () => {
|
||||
|
||||
test("CSV export skips plugin hosts instead of discarding opaque provider configuration", () => {
|
||||
const pluginHost: Host = {
|
||||
...hostDefaults,
|
||||
id: "plugin-host",
|
||||
label: "Plugin host",
|
||||
hostname: "com.example.transport.connection",
|
||||
@@ -174,3 +184,244 @@ test("CSV export skips plugin hosts instead of discarding opaque provider config
|
||||
assert.equal(result.skippedCount, 1);
|
||||
assert.equal(result.csv.includes("opaque://target"), false);
|
||||
});
|
||||
|
||||
test("CSV round-trips inline HTTP and SOCKS5 proxy configuration", () => {
|
||||
const host: Host = {
|
||||
...hostDefaults,
|
||||
id: "host-proxy",
|
||||
label: "Proxied host",
|
||||
hostname: "target.example.com",
|
||||
username: "root",
|
||||
port: 22,
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 8080,
|
||||
username: "ali=ce",
|
||||
password: "p@a,s\"s",
|
||||
},
|
||||
};
|
||||
|
||||
const { csv } = exportHostsToCsvWithStats([host]);
|
||||
assert.ok(csv.includes("http://ali%3Dce:p%40a%2Cs%22s@proxy.example.com:8080"));
|
||||
assert.ok(csv.includes("Proxy"));
|
||||
|
||||
const imported = importVaultHostsFromText("csv", csv);
|
||||
assert.deepEqual(imported.hosts[0]?.proxyConfig, {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 8080,
|
||||
username: "ali=ce",
|
||||
password: "p@a,s\"s",
|
||||
});
|
||||
});
|
||||
|
||||
test("CSV round-trips command and socks5 proxy configuration", () => {
|
||||
const commandHost: Host = {
|
||||
...hostDefaults,
|
||||
id: "host-command-proxy",
|
||||
label: "Command proxy host",
|
||||
hostname: "target.example.com",
|
||||
username: "root",
|
||||
port: 22,
|
||||
proxyConfig: {
|
||||
type: "command",
|
||||
host: "",
|
||||
port: 0,
|
||||
command: "nc -X connect -x 10.0.0.9:1080 %h %p",
|
||||
},
|
||||
};
|
||||
const socksHost: Host = {
|
||||
...hostDefaults,
|
||||
id: "host-socks-proxy",
|
||||
label: "Socks proxy host",
|
||||
hostname: "target2.example.com",
|
||||
username: "root",
|
||||
port: 22,
|
||||
proxyConfig: {
|
||||
type: "socks5",
|
||||
host: "127.0.0.1",
|
||||
port: 1080,
|
||||
},
|
||||
};
|
||||
|
||||
const { csv } = exportHostsToCsvWithStats([commandHost, socksHost]);
|
||||
const imported = importVaultHostsFromText("csv", csv);
|
||||
assert.deepEqual(imported.hosts[0]?.proxyConfig, commandHost.proxyConfig);
|
||||
assert.deepEqual(imported.hosts[1]?.proxyConfig, socksHost.proxyConfig);
|
||||
});
|
||||
|
||||
test("CSV export materializes a referenced proxy profile and never writes encrypted placeholders", () => {
|
||||
const host: Host = {
|
||||
...hostDefaults,
|
||||
id: "host-profile-proxy",
|
||||
label: "Profile proxy host",
|
||||
hostname: "target.example.com",
|
||||
username: "root",
|
||||
port: 22,
|
||||
proxyProfileId: "profile-1",
|
||||
};
|
||||
|
||||
const { csv } = exportHostsToCsvWithStats([host], {
|
||||
proxyProfiles: [{
|
||||
id: "profile-1",
|
||||
label: "Corp proxy",
|
||||
createdAt: 0,
|
||||
config: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 8080,
|
||||
username: "alice",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
},
|
||||
}],
|
||||
});
|
||||
assert.ok(csv.includes("http://alice@proxy.example.com:8080"));
|
||||
assert.equal(csv.includes("enc:v1:"), false);
|
||||
|
||||
const imported = importVaultHostsFromText("csv", csv);
|
||||
assert.deepEqual(imported.hosts[0]?.proxyConfig, {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 8080,
|
||||
username: "alice",
|
||||
});
|
||||
});
|
||||
|
||||
test("CSV import warns and skips an unrecognized Proxy value", () => {
|
||||
const csv = [
|
||||
"Groups,Label,Tags,Notes,Hostname/IP,Protocol,Port,Username,Password,KeyPath,Passphrase,Proxy",
|
||||
',"Bad proxy",,,bad.example.com,ssh,22,root,,,,"not a proxy"',
|
||||
].join("\r\n");
|
||||
|
||||
const imported = importVaultHostsFromText("csv", csv);
|
||||
assert.equal(imported.hosts.length, 1);
|
||||
assert.equal(imported.hosts[0]?.proxyConfig, undefined);
|
||||
assert.ok(imported.issues.some((issue) => issue.message.includes("row 2") && issue.message.includes("Proxy")));
|
||||
});
|
||||
|
||||
test("CSV resolves proxy identity credentials for inline and profile proxies", () => {
|
||||
const proxyConfig = { type: "socks5" as const, host: "proxy.example.com", port: 1080, identityId: "identity-1" };
|
||||
const hosts: Host[] = [
|
||||
{ ...hostDefaults, id: "inline", label: "Inline", hostname: "inline.example.com", proxyConfig },
|
||||
{ ...hostDefaults, id: "profile", label: "Profile", hostname: "profile.example.com", proxyProfileId: "profile-1" },
|
||||
];
|
||||
const { csv } = exportHostsToCsvWithStats(hosts, {
|
||||
proxyProfiles: [{ id: "profile-1", label: "Proxy", createdAt: 0, config: proxyConfig }],
|
||||
identities: [{ id: "identity-1", label: "Account", username: "alice@work", password: ' p,a:ss"%+ ', authMethod: 'password', created: 0 }],
|
||||
});
|
||||
const imported = importVaultHostsFromText("csv", csv);
|
||||
assert.equal(imported.hosts.length, 2);
|
||||
assert.deepEqual(imported.issues, []);
|
||||
for (const host of imported.hosts) {
|
||||
assert.deepEqual(host.proxyConfig, {
|
||||
type: "socks5", host: "proxy.example.com", port: 1080,
|
||||
username: "alice@work", password: ' p,a:ss"%+ ',
|
||||
});
|
||||
assert.equal(host.proxyProfileId, undefined);
|
||||
}
|
||||
assert.equal(csv.includes("identity-1"), false);
|
||||
});
|
||||
|
||||
test("CSV proxy identity export omits encrypted passwords and stale manual credentials", () => {
|
||||
const proxyConfig = {
|
||||
type: "http" as const, host: "proxy.example.com", port: 8080, identityId: "identity-1",
|
||||
username: "stale-user", password: "stale-password",
|
||||
};
|
||||
const host: Host = { ...hostDefaults, id: "identity-host", label: "Identity", hostname: "target.example.com", proxyConfig };
|
||||
const { csv } = exportHostsToCsvWithStats([host], {
|
||||
identities: [{ id: "identity-1", label: "Account", username: "alice", password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==", authMethod: "password", created: 0 }],
|
||||
});
|
||||
assert.equal(csv.includes("enc%3Av1"), false);
|
||||
assert.equal(csv.includes("stale"), false);
|
||||
assert.deepEqual(importVaultHostsFromText("csv", csv).hosts[0]?.proxyConfig, {
|
||||
type: "http", host: "proxy.example.com", port: 8080, username: "alice",
|
||||
});
|
||||
});
|
||||
|
||||
test("CSV rejects HTTPS proxies without silently downgrading their transport", () => {
|
||||
const imported = importVaultHostsFromText("csv", "Hostname,Proxy\ntarget.example.com,https://alice:secret@proxy.example.com:443");
|
||||
assert.equal(imported.hosts[0]?.proxyConfig, undefined);
|
||||
assert.equal(imported.issues.length, 1);
|
||||
assert.match(imported.issues[0].message, /CSV row 2: Proxy/);
|
||||
assert.equal(imported.issues[0].message.includes("secret"), false);
|
||||
});
|
||||
|
||||
test("CSV preserves inherited proxies and gives host settings priority", () => {
|
||||
const inline = { type: "http" as const, host: "inline.example.com", port: 8080 };
|
||||
const groupProxy = { type: "socks5" as const, host: "group.example.com", port: 1080 };
|
||||
const profileProxy = { type: "http" as const, host: "profile.example.com", port: 3128 };
|
||||
const hosts: Host[] = [
|
||||
{ ...hostDefaults, id: "inherited", label: "Inherited", hostname: "one.example.com", group: "Corp/Dev" },
|
||||
{ ...hostDefaults, id: "profile", label: "Profile", hostname: "two.example.com", group: "Corp/Prod" },
|
||||
{ ...hostDefaults, id: "inline", label: "Inline", hostname: "three.example.com", group: "Corp/Prod", proxyConfig: inline },
|
||||
{ ...hostDefaults, id: "override", label: "Override", hostname: "four.example.com", group: "Corp/Dev", proxyProfileId: "profile-1" },
|
||||
];
|
||||
const original = structuredClone(hosts);
|
||||
const { csv } = exportHostsToCsvWithStats(hosts, {
|
||||
groupConfigs: [{ path: "Corp", proxyConfig: groupProxy }, { path: "Corp/Prod", proxyProfileId: "profile-1" }],
|
||||
proxyProfiles: [{ id: "profile-1", label: "Profile", createdAt: 0, config: profileProxy }],
|
||||
});
|
||||
assert.deepEqual(importVaultHostsFromText("csv", csv).hosts.map((host) => host.proxyConfig), [groupProxy, profileProxy, inline, profileProxy]);
|
||||
assert.deepEqual(hosts, original);
|
||||
});
|
||||
|
||||
test("CSV preserves IPv6 proxies and multiline commands with CSV punctuation", () => {
|
||||
const proxies = [
|
||||
{ type: "socks5" as const, host: "::1", port: 1080 },
|
||||
{ type: "http" as const, host: "2001:db8::1", port: 8080, username: "用户", password: "密@码%" },
|
||||
{ type: "command" as const, host: "", port: 0, command: 'sh -c "printf x,y;\nexec nc %h %p"' },
|
||||
];
|
||||
const { csv } = exportHostsToCsvWithStats(proxies.map((proxyConfig, i) => ({ ...hostDefaults, id: String(i), label: String(i), hostname: `host${i}.example.com`, proxyConfig })));
|
||||
assert.ok(csv.includes("socks5://[::1]:1080"));
|
||||
const imported = importVaultHostsFromText("csv", csv);
|
||||
assert.deepEqual(imported.hosts.map((host) => host.proxyConfig), proxies);
|
||||
assert.deepEqual(imported.issues, []);
|
||||
});
|
||||
|
||||
test("CSV proxy parsing warns on paths, query strings, fragments, and invalid ports", () => {
|
||||
for (const value of [
|
||||
"http://proxy.example:8080/path:80", "http://proxy.example?query:80",
|
||||
"http://proxy.example#fragment:80", "http://proxy.example:0",
|
||||
"http://proxy.example:8080:80", "socks5://::1:1080",
|
||||
"socks5://proxy.example:65536", "http://[::1:8080", "command://",
|
||||
]) {
|
||||
const imported = importVaultHostsFromText("csv", `Hostname,Proxy\ntarget.example.com,${value}`);
|
||||
assert.equal(imported.hosts[0]?.proxyConfig, undefined, value);
|
||||
assert.equal(imported.issues.length, 1, value);
|
||||
}
|
||||
});
|
||||
|
||||
test("CSV template includes a working proxy example and legacy CSV stays compatible", () => {
|
||||
const imported = importVaultHostsFromText("csv", getVaultCsvTemplate());
|
||||
assert.equal(imported.hosts.length, 3);
|
||||
assert.deepEqual(imported.hosts[0]?.proxyConfig, { type: "socks5", host: "127.0.0.1", port: 1080 });
|
||||
assert.equal(imported.hosts[1]?.proxyConfig, undefined);
|
||||
assert.deepEqual(imported.issues, []);
|
||||
const legacy = importVaultHostsFromText("csv", "Hostname,Username,Password\nlegacy.example.com,root,secret");
|
||||
assert.equal(legacy.hosts[0]?.password, "secret");
|
||||
assert.equal(legacy.hosts[0]?.proxyConfig, undefined);
|
||||
assert.deepEqual(legacy.issues, []);
|
||||
});
|
||||
|
||||
test("CSV reports unreadable proxy passwords for exported hosts only", () => {
|
||||
const encrypted = "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==";
|
||||
const inline = { type: "http" as const, host: "proxy.example.com", port: 8080, username: "alice", password: encrypted };
|
||||
const identityProxy = { type: "socks5" as const, host: "proxy.example.com", port: 1080, identityId: "identity-1" };
|
||||
const result = exportHostsToCsvWithStats([
|
||||
{ ...hostDefaults, id: "inline", label: "Inline", hostname: "one.example.com", proxyConfig: inline },
|
||||
{ ...hostDefaults, id: "group", label: "Group", hostname: "two.example.com", group: "Corp" },
|
||||
{ ...hostDefaults, id: "good", label: "Readable", hostname: "three.example.com", proxyConfig: { ...inline, password: "readable" } },
|
||||
{ ...hostDefaults, id: "serial", label: "Serial", hostname: "ttyUSB0", protocol: "serial", proxyConfig: inline },
|
||||
], {
|
||||
groupConfigs: [{ path: "Corp", proxyProfileId: "profile-1" }],
|
||||
proxyProfiles: [{ id: "profile-1", label: "Profile", createdAt: 0, config: identityProxy }],
|
||||
identities: [{ id: "identity-1", label: "Account", username: "alice", password: encrypted, authMethod: "password", created: 0 }],
|
||||
});
|
||||
assert.equal(result.unreadableProxyCredentialCount, 2);
|
||||
assert.equal(result.exportedCount, 3);
|
||||
assert.equal(result.skippedCount, 1);
|
||||
const imported = importVaultHostsFromText("csv", result.csv);
|
||||
assert.deepEqual(imported.hosts.map((host) => host.proxyConfig?.password), [undefined, undefined, "readable"]);
|
||||
assert.equal(exportHostsToCsvWithStats([]).unreadableProxyCredentialCount, 0);
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Host } from '../models';
|
||||
import type { GroupConfig, Host, Identity, ProxyProfile } from '../models';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from '../groupConfig';
|
||||
import { isPluginHostProtocol } from '../pluginConnection';
|
||||
import { encodeCsvKeyPath, encodeCsvPassphrase } from './csvCredentialFields';
|
||||
import { hasUnreadableProxyCredential, materializeHostProxyProfile, resolveProxyConfigAuth } from '../proxyProfiles';
|
||||
import { encodeCsvKeyPath, encodeCsvPassphrase, encodeCsvProxy } from './csvCredentialFields';
|
||||
import { formatCsvProxy } from './csvProxy';
|
||||
|
||||
const UTF8_BOM = "\uFEFF";
|
||||
|
||||
@@ -12,6 +15,9 @@ export interface VaultCsvExportOptions {
|
||||
keyPassphrases?: ReadonlyMap<string, string>;
|
||||
keyPassphrasesById?: ReadonlyMap<string, string>;
|
||||
keyPathsById?: ReadonlyMap<string, string>;
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
identities?: Identity[];
|
||||
groupConfigs?: GroupConfig[];
|
||||
}
|
||||
|
||||
export const resolveVaultCsvHostKeyPath = (
|
||||
@@ -30,10 +36,10 @@ 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"];
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy"];
|
||||
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", ""]);
|
||||
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"]);
|
||||
rows.push(["Project/Prod", "Web Server (prod)", "prod,web", "Production", "server-a.example.com", "ssh", "22", "ubuntu", "", "", ""]);
|
||||
rows.push(["Database", "DB", "db,mysql", "MySQL primary", "db.example.com", "ssh", "4567", "admin", "", "", ""]);
|
||||
}
|
||||
@@ -47,9 +53,10 @@ export const getVaultCsvTemplate = (
|
||||
return rows.map((r) => r.map((c) => escapeCsv(c)).join(",")).join("\r\n") + "\r\n";
|
||||
};
|
||||
|
||||
const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions): string => {
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase"];
|
||||
const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy"];
|
||||
const rows: string[][] = [header];
|
||||
let unreadableProxyCredentialCount = 0;
|
||||
|
||||
const escapeCsv = (value: string, skipFormulaGuard = false) => {
|
||||
// Prevent CSV formula injection by prefixing dangerous characters with a single quote
|
||||
@@ -80,6 +87,8 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions): string
|
||||
return hostname;
|
||||
};
|
||||
|
||||
const proxyProfiles = options.proxyProfiles ?? [];
|
||||
const inheritanceOptions = { validProxyProfileIds: new Set(proxyProfiles.map((profile) => profile.id)) };
|
||||
for (const host of exportableHosts) {
|
||||
// For telnet hosts, use telnet-specific port and username
|
||||
const isTelnet = host.protocol === "telnet";
|
||||
@@ -97,6 +106,18 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions): string
|
||||
: (options.keyPassphrases?.get(keyPath) ?? "")
|
||||
)
|
||||
: "";
|
||||
// Proxy profiles are materialized inline so the CSV stays self-contained;
|
||||
// encrypted credential placeholders are never written to the file.
|
||||
const effectiveHost = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, options.groupConfigs ?? [], inheritanceOptions), inheritanceOptions)
|
||||
: host;
|
||||
const proxyConfig = materializeHostProxyProfile(effectiveHost, proxyProfiles).proxyConfig;
|
||||
if (hasUnreadableProxyCredential(proxyConfig, options.identities)) {
|
||||
unreadableProxyCredentialCount += 1;
|
||||
}
|
||||
const proxyValue = proxyConfig
|
||||
? formatCsvProxy(resolveProxyConfigAuth(proxyConfig, options.identities))
|
||||
: "";
|
||||
|
||||
rows.push([
|
||||
host.group ?? "",
|
||||
@@ -110,22 +131,26 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions): string
|
||||
host.password ?? "",
|
||||
encodeCsvKeyPath(keyPath),
|
||||
encodeCsvPassphrase(passphrase),
|
||||
encodeCsvProxy(proxyValue),
|
||||
]);
|
||||
}
|
||||
|
||||
const passwordColIdx = header.indexOf("Password");
|
||||
const keyPathColIdx = header.indexOf("KeyPath");
|
||||
const passphraseColIdx = header.indexOf("Passphrase");
|
||||
return rows.map((r, rowIdx) => r.map((c, i) => escapeCsv(
|
||||
const proxyColIdx = header.indexOf("Proxy");
|
||||
const csv = rows.map((r, rowIdx) => r.map((c, i) => escapeCsv(
|
||||
c,
|
||||
rowIdx > 0 && (i === passwordColIdx || i === keyPathColIdx || i === passphraseColIdx),
|
||||
rowIdx > 0 && (i === passwordColIdx || i === keyPathColIdx || i === passphraseColIdx || i === proxyColIdx),
|
||||
)).join(",")).join("\r\n") + "\r\n";
|
||||
return { csv, unreadableProxyCredentialCount };
|
||||
};
|
||||
|
||||
interface ExportHostsResult {
|
||||
csv: string;
|
||||
exportedCount: number;
|
||||
skippedCount: number;
|
||||
unreadableProxyCredentialCount: number;
|
||||
}
|
||||
|
||||
export const exportHostsToCsvWithStats = (
|
||||
@@ -137,10 +162,12 @@ export const exportHostsToCsvWithStats = (
|
||||
const isUnsupported = (h: Host) => h.protocol === "serial" || isPluginHostProtocol(h.protocol);
|
||||
const skippedHosts = hosts.filter((h) => isUnsupported(h));
|
||||
const exportableHosts = hosts.filter((h) => !isUnsupported(h));
|
||||
const { csv, unreadableProxyCredentialCount } = exportHostsToCsv(exportableHosts, options);
|
||||
|
||||
return {
|
||||
csv: UTF8_BOM + exportHostsToCsv(exportableHosts, options),
|
||||
csv: UTF8_BOM + csv,
|
||||
exportedCount: exportableHosts.length,
|
||||
skippedCount: skippedHosts.length,
|
||||
unreadableProxyCredentialCount,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ProxyConfig } from "../models";
|
||||
import { decodeCsvProxy } from "./csvCredentialFields";
|
||||
|
||||
// CSV representation of a per-host proxy configuration:
|
||||
// http://[user[:pass]@]host:port
|
||||
// socks5://[user[:pass]@]host:port
|
||||
// command://<command text, verbatim after the scheme>
|
||||
const PROXY_SCHEME_PATTERN = /^(http|socks5|command):\/\/(.*)$/is;
|
||||
const PROXY_AUTHORITY_PATTERN = /^(?:([^@:/?#\s]+)(?::([^@/?#]*))?@)?(\[[^\[\]@/?#\s]+\]|[^\[\]@:/?#\s]+):(\d+)$/u;
|
||||
|
||||
const decodeAuthorityPart = (value: string | undefined): string | undefined => {
|
||||
if (value === undefined) return undefined;
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatCsvProxy = (config: ProxyConfig): string => {
|
||||
if (config.type === "command") {
|
||||
return `command://${config.command ?? ""}`;
|
||||
}
|
||||
const scheme = config.type === "socks5" ? "socks5" : "http";
|
||||
const username = config.username?.trim();
|
||||
const auth = username
|
||||
? `${encodeURIComponent(username)}${config.password ? `:${encodeURIComponent(config.password)}` : ""}@`
|
||||
: "";
|
||||
const host = config.host.trim();
|
||||
const authorityHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
return `${scheme}://${auth}${authorityHost}:${config.port}`;
|
||||
};
|
||||
|
||||
export const parseCsvProxy = (raw: string): ProxyConfig | undefined => {
|
||||
const value = decodeCsvProxy(raw.trim());
|
||||
if (!value) return undefined;
|
||||
const match = value.match(PROXY_SCHEME_PATTERN);
|
||||
if (!match) return undefined;
|
||||
const scheme = match[1].toLowerCase();
|
||||
if (scheme === "command") {
|
||||
const command = match[2].trim();
|
||||
if (!command) return undefined;
|
||||
return { type: "command", host: "", port: 0, command };
|
||||
}
|
||||
if (/[\s\0]/.test(match[2])) return undefined;
|
||||
const authority = match[2].match(PROXY_AUTHORITY_PATTERN);
|
||||
if (!authority) return undefined;
|
||||
const host = (authority[3] ?? "").trim().replace(/^\[(.*)\]$/u, "$1");
|
||||
const port = Number(authority[4]);
|
||||
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) return undefined;
|
||||
const config: ProxyConfig = {
|
||||
type: scheme === "socks5" ? "socks5" : "http",
|
||||
host,
|
||||
port,
|
||||
};
|
||||
const username = decodeAuthorityPart(authority[1]);
|
||||
const password = decodeAuthorityPart(authority[2]);
|
||||
if (username) config.username = username;
|
||||
if (password) config.password = password;
|
||||
return config;
|
||||
};
|
||||
Reference in new issue
Block a user