Files
Netcatty/domain/sshConfigSerializer.ts
T
bincxz c9f1951e28 fix: address Codex review — quoted paths, stale keys, managed source round-trip
P1: serializeHostsToSshConfig now emits IdentityFile directives so
managed ssh_config sources preserve key paths on sync. Paths with
spaces are automatically quoted.

P2: Unquote IdentityFile paths during import — ssh_config allows
quoted paths for filenames with spaces, but the quotes were stored
literally and caused fs.readFile to fail.

P2: Clear identityFilePaths when applying an identity profile, and
only forward them at connection time when no vault key is selected.
Prevents stale local key paths from triggering unrelated passphrase
prompts after switching to a different credential source.

P1 (SFTP): Forward identityFilePaths for jump hosts in SFTP credentials.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:59:36 +08:00

232 lines
7.5 KiB
TypeScript

import { Host } from "./models";
const DEFAULT_SSH_PORT = 22;
const MANAGED_BLOCK_BEGIN = "# BEGIN NETCATTY MANAGED - DO NOT EDIT THIS BLOCK";
const MANAGED_BLOCK_END = "# END NETCATTY MANAGED";
/**
* Check if a string is an IPv6 address
*/
const isIPv6 = (hostname: string): boolean => {
// IPv6 addresses contain colons and may be wrapped in brackets
return hostname.includes(':') && !hostname.startsWith('[');
};
/**
* Serialize a single jump host to ProxyJump format
* Format: [user@]host[:port]
* @param host - The jump host to serialize
* @param managedHostIds - Set of host IDs that have Host blocks in the managed config
*/
const serializeJumpHost = (host: Host, managedHostIds: Set<string>): string => {
let result = "";
if (host.username) {
result += `${host.username}@`;
}
// Only use label as alias if this jump host is in the managed hosts (has a Host block)
// and sanitize it by removing spaces. Otherwise use hostname directly.
let hostPart: string;
if (managedHostIds.has(host.id) && host.label) {
// Use sanitized label (same as the Host block alias)
hostPart = host.label.replace(/\s/g, '') || host.hostname;
} else {
// Jump host is outside managed config, use hostname directly
hostPart = host.hostname;
}
// For IPv6 addresses, always wrap in brackets to disambiguate colons
// OpenSSH requires brackets for IPv6 in ProxyJump regardless of port
if (isIPv6(hostPart)) {
result += `[${hostPart}]`;
if (host.port && host.port !== DEFAULT_SSH_PORT) {
result += `:${host.port}`;
}
} else {
result += hostPart;
if (host.port && host.port !== DEFAULT_SSH_PORT) {
result += `:${host.port}`;
}
}
return result;
};
/**
* Build ProxyJump directive from hostChain
* @param host - The host with hostChain
* @param allHosts - All hosts to look up jump host details
* @param managedHostIds - Set of host IDs that have Host blocks in the managed config
* @returns ProxyJump value string or null if chain is empty/invalid
*/
const buildProxyJumpValue = (
host: Host,
allHosts: Host[],
managedHostIds: Set<string>,
): string | null => {
if (!host.hostChain?.hostIds || host.hostChain.hostIds.length === 0) {
return null;
}
const hostMap = new Map(allHosts.map(h => [h.id, h]));
const jumpParts: string[] = [];
for (const jumpHostId of host.hostChain.hostIds) {
const jumpHost = hostMap.get(jumpHostId);
if (jumpHost) {
jumpParts.push(serializeJumpHost(jumpHost, managedHostIds));
}
}
return jumpParts.length > 0 ? jumpParts.join(",") : null;
};
export const serializeHostsToSshConfig = (hosts: Host[], allHosts?: Host[]): string => {
const blocks: string[] = [];
// Use provided allHosts for jump host lookup, or fall back to hosts array
const hostsForLookup = allHosts || hosts;
// Build set of managed host IDs (SSH hosts that will have Host blocks)
const managedHostIds = new Set(
hosts
.filter(h => !h.protocol || h.protocol === "ssh")
.map(h => h.id)
);
for (const host of hosts) {
if (host.protocol && host.protocol !== "ssh") continue;
const lines: string[] = [];
// Sanitize alias by removing spaces (SSH config doesn't allow spaces in Host patterns)
const alias = (host.label?.replace(/\s/g, '') || host.hostname);
lines.push(`Host ${alias}`);
if (host.hostname !== alias) {
lines.push(` HostName ${host.hostname}`);
}
if (host.username) {
lines.push(` User ${host.username}`);
}
if (host.port && host.port !== DEFAULT_SSH_PORT) {
lines.push(` Port ${host.port}`);
}
// Serialize IdentityFile paths
if (host.identityFilePaths && host.identityFilePaths.length > 0) {
for (const keyPath of host.identityFilePaths) {
// Quote paths that contain spaces
const formatted = keyPath.includes(" ") ? `"${keyPath}"` : keyPath;
lines.push(` IdentityFile ${formatted}`);
}
}
// Serialize ProxyJump if host has a chain
const proxyJumpValue = buildProxyJumpValue(host, hostsForLookup, managedHostIds);
if (proxyJumpValue) {
lines.push(` ProxyJump ${proxyJumpValue}`);
}
blocks.push(lines.join("\n"));
}
return blocks.join("\n\n") + "\n";
};
export const mergeWithExistingSshConfig = (
existingContent: string,
managedHosts: Host[],
managedHostnameSet: Set<string>,
allHosts?: Host[],
): string => {
const lines = existingContent.split(/\r?\n/);
const preservedBlocks: string[] = [];
// Track preamble lines (comments/blank lines before first Host/Match block)
let preambleLines: string[] = [];
let seenFirstBlock = false;
let currentBlock: string[] = [];
let currentHostPatterns: string[] = [];
let isMatchBlock = false; // Track if current block is a Match block (always preserve)
const flush = () => {
if (currentBlock.length > 0) {
// Match blocks are always preserved (we don't manage them)
if (isMatchBlock) {
preservedBlocks.push(currentBlock.join("\n"));
} else {
// Filter out managed patterns from the Host line, keep non-managed ones
const nonManagedPatterns = currentHostPatterns.filter(
(p) => !managedHostnameSet.has(p.toLowerCase())
);
if (nonManagedPatterns.length === currentHostPatterns.length) {
// No managed patterns - preserve the entire block as-is
preservedBlocks.push(currentBlock.join("\n"));
} else if (nonManagedPatterns.length > 0) {
// Some patterns are managed, some are not - rewrite Host line with only non-managed patterns
const newHostLine = `Host ${nonManagedPatterns.join(" ")}`;
const restOfBlock = currentBlock.slice(1); // Everything after Host line
preservedBlocks.push([newHostLine, ...restOfBlock].join("\n"));
}
// If all patterns are managed (nonManagedPatterns.length === 0), drop the entire block
}
currentBlock = [];
currentHostPatterns = [];
isMatchBlock = false;
}
};
for (const line of lines) {
const trimmed = line.replace(/#.*/, "").trim();
const tokens = trimmed.split(/\s+/).filter(Boolean);
const keyword = tokens[0]?.toLowerCase();
if (keyword === "host") {
flush();
seenFirstBlock = true;
currentHostPatterns = tokens.slice(1);
currentBlock.push(line);
} else if (keyword === "match") {
flush();
seenFirstBlock = true;
isMatchBlock = true;
currentBlock.push(line);
} else if (!seenFirstBlock) {
// Preserve preamble lines (comments, blank lines before first block)
preambleLines.push(line);
} else if (currentBlock.length > 0) {
// Inside a block - add to current block
currentBlock.push(line);
} else {
// Between blocks (comments/blank lines after a block ended)
// These will be included with the next block or preserved separately
currentBlock.push(line);
}
}
flush();
const managedContent = serializeHostsToSshConfig(managedHosts, allHosts);
const managedBlock = `${MANAGED_BLOCK_BEGIN}\n${managedContent}${MANAGED_BLOCK_END}\n`;
const preserved = preservedBlocks.join("\n\n");
// Build final output: preamble + preserved blocks + managed block
const parts: string[] = [];
// Add preamble if it has content (trim trailing empty lines but keep structure)
const preamble = preambleLines.join("\n");
if (preamble.trim()) {
parts.push(preamble);
}
if (preserved.trim()) {
parts.push(preserved);
}
parts.push(managedBlock);
return parts.join("\n\n");
};