Files
Netcatty/domain/sshConfigSerializer.ts
陈大猫 08e566adb0 [codex] Add X11 forwarding support (#835)
* Add X11 forwarding support

* Address X11 forwarding review feedback

* Handle X11 auth for unix socket display paths

* Tighten X11 forwarding compatibility handling
2026-04-28 07:54:26 +08:00

236 lines
7.6 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}`);
}
if (host.x11Forwarding && !host.moshEnabled) {
lines.push(" ForwardX11 yes");
}
// 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");
};