From f4819a58fe3770e62b363a53c41f18bb364cd236 Mon Sep 17 00:00:00 2001 From: Jokin Date: Mon, 13 Jul 2026 14:10:52 +0200 Subject: [PATCH] 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 --- bun.lock | 3 + package.json | 1 + src/bun/fs/sftp.ts | 37 +++-- src/bun/rpc-handlers.ts | 3 + src/bun/store/ssh-config.ts | 127 ++++++++++++++++++ src/shared/rpc.ts | 5 + src/shared/types.ts | 13 +- .../connection-manager/connection-manager.ts | 3 +- ui/src/app/connections.service.ts | 15 ++- ui/src/app/file-panel/file-panel.html | 20 ++- ui/src/app/file-panel/file-panel.ts | 31 ++++- 11 files changed, 240 insertions(+), 18 deletions(-) create mode 100644 src/bun/store/ssh-config.ts diff --git a/bun.lock b/bun.lock index a1e38a6..c87fc44 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "bunscp", "dependencies": { "electrobun": "1.18.1", + "ssh-config": "^5.2.0", "ssh2": "^1.17.0", }, "devDependencies": { @@ -1019,6 +1020,8 @@ "spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="], + "ssh-config": ["ssh-config@5.2.0", "", {}, "sha512-rvPlm2RlagYjTGP+maYV4nN513imsk0z/sRMMaPwNZqeLIbS9sM/uRCJlOHc8so+xJtdjQEnYUPCDP+92G+6Gg=="], + "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="], "ssri": ["ssri@13.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="], diff --git a/package.json b/package.json index 42dac62..51be5a7 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "electrobun": "1.18.1", + "ssh-config": "^5.2.0", "ssh2": "^1.17.0" }, "devDependencies": { diff --git a/src/bun/fs/sftp.ts b/src/bun/fs/sftp.ts index a45287a..02f3241 100644 --- a/src/bun/fs/sftp.ts +++ b/src/bun/fs/sftp.ts @@ -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 diff --git a/src/bun/rpc-handlers.ts b/src/bun/rpc-handlers.ts index 5c7e17b..637eba9 100644 --- a/src/bun/rpc-handlers.ts +++ b/src/bun/rpc-handlers.ts @@ -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() })), diff --git a/src/bun/store/ssh-config.ts b/src/bun/store/ssh-config.ts new file mode 100644 index 0000000..62efa5c --- /dev/null +++ b/src/bun/store/ssh-config.ts @@ -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; + } +} diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index 1b10792..d639562 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -7,6 +7,7 @@ import type { FileEntry, Result, SavedConnection, + SshConfigHost, TransferSnapshot, } from "./types"; @@ -68,6 +69,10 @@ export type AppRPC = { deleteConnection: { params: { id: string }; response: Result }; upsertFolder: { params: { folder: ConnFolder }; response: Result<{ id: string }> }; deleteFolder: { params: { id: string }; response: Result }; + getSshConfigHosts: { + params: Record; + response: Result<{ hosts: SshConfigHost[] }>; + }; // settings getSettings: { params: Record; diff --git a/src/shared/types.ts b/src/shared/types.ts index b1b58b5..a970546 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -14,7 +14,8 @@ export type Result = ({ ok: true } & T) | { ok: false; error: string export type AuthConfig = | { kind: "password"; password: string } - | { kind: "key"; privateKeyPath: string; passphrase?: string }; + | { kind: "key"; privateKeyPath: string; passphrase?: string } + | { kind: "agent" }; export interface ConnectionConfig { host: string; @@ -35,6 +36,16 @@ export interface ConnFolder { name: string; } +/** A concrete host alias resolved from ~/.ssh/config (read-only). */ +export interface SshConfigHost { + name: string; // the Host alias as written + hostname: string; // resolved HostName (defaults to alias) + port: number; + username: string; + identityFile?: string; // first existing IdentityFile, absolute path + unsupported?: "proxy"; // ProxyJump/ProxyCommand present — direct connect can't work +} + export interface ConnectionsFile { version: 1; folders: ConnFolder[]; diff --git a/ui/src/app/connection-manager/connection-manager.ts b/ui/src/app/connection-manager/connection-manager.ts index 1655c61..eaf7a5d 100644 --- a/ui/src/app/connection-manager/connection-manager.ts +++ b/ui/src/app/connection-manager/connection-manager.ts @@ -28,7 +28,8 @@ function toDraft(c: SavedConnection): Draft { host: c.host, port: c.port, username: c.username, - authKind: c.auth.kind, + // agent auth only exists on ~/.ssh/config hosts, which never reach this form + authKind: c.auth.kind === 'password' ? 'password' : 'key', password: c.auth.kind === 'password' ? c.auth.password : '', privateKeyPath: c.auth.kind === 'key' ? c.auth.privateKeyPath : '', passphrase: c.auth.kind === 'key' ? (c.auth.passphrase ?? '') : '', diff --git a/ui/src/app/connections.service.ts b/ui/src/app/connections.service.ts index 9b8efb5..c47ab29 100644 --- a/ui/src/app/connections.service.ts +++ b/ui/src/app/connections.service.ts @@ -1,5 +1,5 @@ import { Injectable, computed, inject, signal } from '@angular/core'; -import type { ConnFolder, SavedConnection } from '@shared/types'; +import type { ConnFolder, SavedConnection, SshConfigHost } from '@shared/types'; import { RpcService } from './rpc.service'; import { ToastService } from './toast.service'; @@ -10,6 +10,8 @@ export class ConnectionsService { readonly folders = signal([]); readonly connections = signal([]); + /** Read-only hosts from ~/.ssh/config; empty when the file is absent or unreadable. */ + readonly sshHosts = signal([]); /** Root-level connections plus one group per folder (folders always shown). */ readonly grouped = computed(() => { @@ -24,7 +26,16 @@ export class ConnectionsService { }); constructor() { - if (this.rpc.available) void this.reload(); + if (this.rpc.available) { + void this.reload(); + void this.reloadSshHosts(); + } + } + + /** Silent on failure — a broken ssh config just means an empty group. */ + async reloadSshHosts(): Promise { + const res = await this.rpc.request.getSshConfigHosts({}); + this.sshHosts.set(res.ok ? res.hosts : []); } async reload(): Promise { diff --git a/ui/src/app/file-panel/file-panel.html b/ui/src/app/file-panel/file-panel.html index 79365b9..ba18ec5 100644 --- a/ui/src/app/file-panel/file-panel.html +++ b/ui/src/app/file-panel/file-panel.html @@ -1,7 +1,7 @@
+ } + } +