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:
+25
-12
@@ -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
|
||||
|
||||
@@ -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() })),
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user