import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import SSHConfig, { type Directive, LineType, type Section } from "ssh-config";
import type { HostConfig } from "../types.js";
/**
* Expand tilde (~) in paths to user's home directory
*/
function expandTildePath(path: string): string {
if (path.startsWith("~/")) {
return join(homedir(), path.slice(2));
}
if (path === "~") {
return homedir();
}
return path;
}
/**
* Check if a host pattern contains wildcards or special characters
* Skip hosts like *, *.example.com, etc.
*/
function isPatternHost(hostName: string): boolean {
return hostName.includes("*") || hostName.includes("?");
}
/**
* Hosts to skip - these are not Docker hosts
* (git hosting services, backup servers, etc.)
*/
const SKIP_HOSTS = new Set([
"github.com",
"gitlab.com",
"bitbucket.org",
"ssh.github.com",
"backup.unraid.net",
]);
/**
* Helper to get string value from SSH config value
*/
function getStringValue(value: string | { val: string }[]): string {
if (typeof value === "string") {
return value;
}
if (Array.isArray(value) && value.length > 0) {
return value[0].val;
}
return "";
}
type SSHGlobalDefaults = {
user?: string;
port?: number;
identityFile?: string;
};
/**
* Extract defaults from `Host *` block so individual hosts inherit global settings.
*/
function getGlobalDefaults(config: ReturnType<typeof SSHConfig.parse>): SSHGlobalDefaults {
const defaults: SSHGlobalDefaults = {};
const globalSection = config.find({ Host: "*" }) as Section | undefined;
if (!globalSection || !("config" in globalSection)) {
return defaults;
}
const user = globalSection.config.find((line) => {
return line.type === LineType.DIRECTIVE && (line as Directive).param === "User";
}) as Directive | undefined;
if (user?.value) {
defaults.user = getStringValue(user.value);
}
const port = globalSection.config.find((line) => {
return line.type === LineType.DIRECTIVE && (line as Directive).param === "Port";
}) as Directive | undefined;
if (port?.value) {
const portNum = Number.parseInt(getStringValue(port.value), 10);
if (!Number.isNaN(portNum)) {
defaults.port = portNum;
}
}
const identityFile = globalSection.config.find((line) => {
return line.type === LineType.DIRECTIVE && (line as Directive).param === "IdentityFile";
}) as Directive | undefined;
if (identityFile?.value) {
defaults.identityFile = expandTildePath(getStringValue(identityFile.value));
}
return defaults;
}
/**
* Load host configurations from SSH config file
*
* @param configPath - Path to SSH config file (defaults to ~/.ssh/config)
* @returns Promise resolving to array of HostConfig objects parsed from SSH config
*
* @example
* ```typescript
* const hosts = await loadFromSSHConfig("~/.ssh/config");
* // Returns: [{ name: "server1", host: "192.168.1.100", protocol: "ssh", ... }]
* ```
*/
export async function loadFromSSHConfig(
configPath: string = join(homedir(), ".ssh", "config")
): Promise<HostConfig[]> {
const _debug = Boolean(process.env.SYNAPSE_DEBUG);
// Return empty array if config doesn't exist
if (!existsSync(configPath)) {
return [];
}
try {
const configContent = await readFile(configPath, "utf-8");
const config = SSHConfig.parse(configContent);
const globalDefaults = getGlobalDefaults(config);
const hosts: HostConfig[] = [];
for (const section of config) {
// Only process Host sections (not Match or other directives)
if (section.type !== LineType.DIRECTIVE) {
continue;
}
const directive = section as Directive;
if (directive.param !== "Host") {
continue;
}
const hostValue = getStringValue(directive.value);
// Skip wildcard hosts, pattern hosts, and known non-Docker hosts
if (
!hostValue ||
hostValue === "*" ||
isPatternHost(hostValue) ||
SKIP_HOSTS.has(hostValue)
) {
continue;
}
// Find the config section for this host
const hostSection = config.find({ Host: hostValue }) as Section | undefined;
if (!hostSection || !("config" in hostSection)) {
continue;
}
// Extract hostname - required field
const hostname = hostSection.config.find((line) => {
return line.type === LineType.DIRECTIVE && (line as Directive).param === "HostName";
}) as Directive | undefined;
// Skip hosts without HostName (they're just aliases)
if (!hostname || !hostname.value) {
continue;
}
// Build HostConfig object
const hostConfig: HostConfig = {
name: hostValue,
host: getStringValue(hostname.value),
protocol: "ssh" as const,
};
// Extract optional User
const user = hostSection.config.find((line) => {
return line.type === LineType.DIRECTIVE && (line as Directive).param === "User";
}) as Directive | undefined;
if (user?.value) {
hostConfig.sshUser = getStringValue(user.value);
} else if (globalDefaults.user) {
hostConfig.sshUser = globalDefaults.user;
}
// Extract optional Port
const port = hostSection.config.find((line) => {
return line.type === LineType.DIRECTIVE && (line as Directive).param === "Port";
}) as Directive | undefined;
if (port?.value) {
const portNum = Number.parseInt(getStringValue(port.value), 10);
if (!Number.isNaN(portNum)) {
hostConfig.port = portNum;
}
} else if (globalDefaults.port) {
hostConfig.port = globalDefaults.port;
}
// Extract optional IdentityFile
const identityFile = hostSection.config.find((line) => {
return line.type === LineType.DIRECTIVE && (line as Directive).param === "IdentityFile";
}) as Directive | undefined;
if (identityFile?.value) {
const rawPath = getStringValue(identityFile.value);
const expandedPath = expandTildePath(rawPath);
hostConfig.sshKeyPath = expandedPath;
} else if (globalDefaults.identityFile) {
hostConfig.sshKeyPath = globalDefaults.identityFile;
}
hosts.push(hostConfig);
}
return hosts;
} catch (_error) {
// Log error but don't crash - return empty array on parse errors
return [];
}
}
/**
* Merge host configurations with manual config taking precedence
*
* Manual hosts completely replace SSH config hosts with the same name.
* No property merging - manual config is used as-is.
*
* @param sshConfigHosts - Hosts loaded from SSH config
* @param manualHosts - Hosts from manual configuration (config file or env var)
* @returns Merged array with manual hosts taking precedence
*
* @example
* ```typescript
* const sshHosts = [{ name: "server1", host: "192.168.1.100", protocol: "ssh" }];
* const manualHosts = [{ name: "server1", host: "10.0.0.100", protocol: "http" }];
* const merged = mergeHostConfigs(sshHosts, manualHosts);
* // Result: [{ name: "server1", host: "10.0.0.100", protocol: "http" }]
* // Manual config completely replaces SSH config
* ```
*/
export function mergeHostConfigs(
sshConfigHosts: HostConfig[],
manualHosts: HostConfig[]
): HostConfig[] {
// Create a map of manual hosts by name for fast lookup
const manualHostMap = new Map<string, HostConfig>();
for (const host of manualHosts) {
manualHostMap.set(host.name, host);
}
// Start with all manual hosts
const merged = [...manualHosts];
// Add SSH hosts that don't conflict with manual hosts
for (const sshHost of sshConfigHosts) {
if (!manualHostMap.has(sshHost.name)) {
merged.push(sshHost);
}
}
return merged;
}