List ~/.ssh/config hosts in the panel source dropdown

Read-only group below saved connections; hosts resolve HostName/User/
Port/IdentityFile (Include, wildcards, %-tokens) and connect on click.
New ssh-agent auth kind covers hosts without a usable identity file;
ProxyJump/ProxyCommand hosts are shown disabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 14:10:52 +02:00
co-authored by Claude Fable 5
parent fdf65766b5
commit f4819a58fe
11 changed files with 240 additions and 18 deletions
+25 -12
View File
@@ -1,9 +1,9 @@
import dns from "node:dns/promises";
import { readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { Client, type ConnectConfig, type SFTPWrapper } from "ssh2";
import type { ConnectionConfig, EntryType, FileEntry } from "../../shared/types";
import { expandTilde } from "../store/ssh-config";
import type { FileSystemProvider } from "./provider";
const S_IFMT = 0o170000;
@@ -17,12 +17,6 @@ function typeFromMode(mode: number): EntryType {
return "file";
}
function expandTilde(p: string): string {
if (p === "~") return os.homedir();
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
return p;
}
export class SftpProvider implements FileSystemProvider {
readonly kind = "sftp" as const;
readonly fsKey: string;
@@ -49,11 +43,30 @@ export class SftpProvider implements FileSystemProvider {
keepaliveInterval: 15_000,
keepaliveCountMax: 3,
};
if (config.auth.kind === "password") {
connectConfig.password = config.auth.password;
} else {
connectConfig.privateKey = readFileSync(expandTilde(config.auth.privateKeyPath));
if (config.auth.passphrase) connectConfig.passphrase = config.auth.passphrase;
const agentSock =
process.env.SSH_AUTH_SOCK ??
(process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : undefined);
switch (config.auth.kind) {
case "password":
connectConfig.password = config.auth.password;
break;
case "agent":
if (!agentSock) throw new Error("ssh-agent not available (SSH_AUTH_SOCK is not set)");
connectConfig.agent = agentSock;
break;
case "key": {
// Alongside a passphrase-less key file, offer the agent too — it
// covers encrypted keys already loaded via ssh-add/UseKeychain.
try {
connectConfig.privateKey = readFileSync(expandTilde(config.auth.privateKeyPath));
} catch {
if (!agentSock)
throw new Error(`identity file not found: ${config.auth.privateKeyPath}`);
}
if (config.auth.passphrase) connectConfig.passphrase = config.auth.passphrase;
else if (agentSock) connectConfig.agent = agentSock;
break;
}
}
// Filesystem identity: resolve the host so a hostname and its IP compare
+3
View File
@@ -7,6 +7,7 @@ import type { SessionRegistry } from "./fs/registry";
import { SftpProvider } from "./fs/sftp";
import type { ConnectionStore } from "./store/connections";
import type { SettingsStore } from "./store/settings";
import { loadSshConfigHosts } from "./store/ssh-config";
import type { TransferEngine } from "./transfers/engine";
import { removeRecursive } from "./transfers/walk";
@@ -165,6 +166,8 @@ export function createAppRPC({
return {};
}),
getSshConfigHosts: () => wrap(async () => ({ hosts: loadSshConfigHosts() })),
// ---- settings ----
getSettings: () => wrap(async () => ({ settings: await settings.load() })),
+127
View File
@@ -0,0 +1,127 @@
import { existsSync, readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import SSHConfig from "ssh-config";
import type { SshConfigHost } from "../../shared/types";
export function expandTilde(p: string): string {
if (p === "~") return os.homedir();
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
return p;
}
const SSH_DIR = path.join(os.homedir(), ".ssh");
const DEFAULT_IDENTITIES = ["id_ed25519", "id_ecdsa", "id_rsa"];
/**
* Splice `Include` directives into the config text before parsing — the
* ssh-config package leaves them as plain directives. Relative paths resolve
* against ~/.ssh, globs are supported, unreadable files are skipped.
*/
function expandIncludes(text: string, depth = 0): string {
if (depth > 3) return text;
return text.replace(/^[ \t]*Include[ \t=]+(.+)$/gim, (line, args: string) => {
const parts = args.trim().split(/\s+/);
const contents: string[] = [];
for (const raw of parts) {
const pattern = expandTilde(raw.replace(/^"|"$/g, ""));
const abs = path.isAbsolute(pattern) ? pattern : path.join(SSH_DIR, pattern);
try {
const files = pattern.includes("*")
? [...new Bun.Glob(path.basename(abs)).scanSync({ cwd: path.dirname(abs), absolute: true })]
: [abs];
for (const file of files.sort()) {
contents.push(expandIncludes(readFileSync(file, "utf8"), depth + 1));
}
} catch {
// unreadable include — same as ssh with a missing optional include
}
}
return contents.length ? contents.join("\n") : `# ${line}`;
});
}
/** ssh %-tokens we support in HostName/IdentityFile; others are left literal. */
function expandTokens(value: string, ctx: { host: string; port: number; user: string }): string {
return value.replace(/%[%hpdru]/g, (token) => {
switch (token) {
case "%%":
return "%";
case "%h":
return ctx.host;
case "%p":
return String(ctx.port);
case "%r":
return ctx.user;
case "%u":
return os.userInfo().username;
case "%d":
return os.homedir();
default:
return token;
}
});
}
function first(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
/**
* Concrete host aliases from ~/.ssh/config, resolved with ssh's
* first-match-wins semantics (wildcard blocks contribute settings but are not
* listed themselves). Missing or malformed config yields an empty list —
* the feature is silently absent, never an error. Match blocks are parsed
* but their criteria are not evaluated (matchExec is disabled).
*/
export function loadSshConfigHosts(): SshConfigHost[] {
let text: string;
try {
text = readFileSync(path.join(SSH_DIR, "config"), "utf8");
} catch {
return [];
}
try {
const config = SSHConfig.parse(expandIncludes(text));
const aliases: string[] = [];
for (const line of config) {
if (line.type !== SSHConfig.DIRECTIVE || line.param.toLowerCase() !== "host") continue;
const values = Array.isArray(line.value) ? line.value.map((v) => v.val) : [line.value];
for (const alias of values) {
if (!/[*?!]/.test(alias) && !aliases.includes(alias)) aliases.push(alias);
}
}
return aliases.map((alias) => resolveHost(config, alias)).filter((h) => h !== null);
} catch (err) {
console.log("[ssh-config] skipped:", err);
return [];
}
}
function resolveHost(config: SSHConfig, alias: string): SshConfigHost | null {
try {
const computed = config.compute(alias, { ignoreCase: true, matchExec: false });
const port = Number(first(computed.port)) || 22;
const username = first(computed.user) ?? os.userInfo().username;
const hostname = expandTokens(first(computed.hostname) ?? alias, {
host: alias,
port,
user: username,
});
const ctx = { host: hostname, port, user: username };
const declared = computed.identityfile;
const candidates = (Array.isArray(declared) ? declared : declared ? [declared] : [])
.map((p) => expandTilde(expandTokens(p, ctx)))
.concat(DEFAULT_IDENTITIES.map((name) => path.join(SSH_DIR, name)));
const identityFile = candidates.find((p) => existsSync(p));
const host: SshConfigHost = { name: alias, hostname, port, username };
if (identityFile) host.identityFile = identityFile;
if (computed.proxyjump || computed.proxycommand) host.unsupported = "proxy";
return host;
} catch (err) {
console.log(`[ssh-config] skipped host ${alias}:`, err);
return null;
}
}