Compare commits

..

4 Commits

Author SHA1 Message Date
陈大猫
4758345448 Merge pull request #136 from Nightsuki/fix/ssh-default-key-fallback-all-keys
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: try all default SSH keys for fallback authentication
2026-01-26 23:33:38 +08:00
Nightsuki
4d3fa93083 fix: try all default SSH keys for fallback authentication
Previously, when no explicit auth method was configured, the code would
only try the first available key (id_ed25519) even if the server only
accepted a different key (id_rsa). This caused authentication failures
when users had multiple SSH keys but only some were authorized.

Changes:
- Add findAllDefaultPrivateKeys() to discover all available keys
- Try ssh-agent first (matching regular SSH behavior)
- Try ALL default keys (id_ed25519, id_ecdsa, id_rsa) in order
- Add debug logging for ssh2 auth flow diagnostics
- Improve auth method ordering: agent -> keys -> password -> keyboard
2026-01-26 23:11:26 +08:00
陈大猫
2746aae274 Merge pull request #135 from binaricat/fix/sftp-local-files-freeze
fix: use async exec for Windows hidden file check to prevent UI freeze
2026-01-26 19:39:22 +08:00
bincxz
a7b22b3580 fix: use async exec for Windows hidden file check to prevent UI freeze
The isWindowsHiddenFile function was using execSync which blocks the
main process. When listing directories with many files on Windows,
this caused the app to freeze and show "No response" until all attrib
commands completed.

Changed to async exec with promisify to allow non-blocking execution.

Fixes #134

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 19:36:44 +08:00
2 changed files with 96 additions and 25 deletions

View File

@@ -6,19 +6,23 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { execSync } = require("node:child_process");
const { exec } = require("node:child_process");
const { promisify } = require("node:util");
const execAsync = promisify(exec);
/**
* Check if a file is hidden on Windows using the attrib command
* Returns true if the file has the hidden attribute set
* Uses async exec to avoid blocking the main process
*/
function isWindowsHiddenFile(filePath) {
async function isWindowsHiddenFile(filePath) {
if (process.platform !== "win32") return false;
try {
const output = execSync(`attrib "${filePath}"`, { encoding: "utf8" });
const { stdout } = await execAsync(`attrib "${filePath}"`);
// attrib output format: " H R filename" where H = hidden, R = read-only, etc.
// The attributes appear in the first ~10 characters before the path
const attrPart = output.substring(0, output.indexOf(filePath)).toUpperCase();
const attrPart = stdout.substring(0, stdout.indexOf(filePath)).toUpperCase();
return attrPart.includes("H");
} catch (err) {
console.warn(`Could not check hidden attribute for ${filePath}:`, err.message);
@@ -67,7 +71,7 @@ async function listLocalDir(event, payload) {
}
// Check for Windows hidden attribute
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
result[i] = {
name: entry.name,
@@ -86,7 +90,7 @@ async function listLocalDir(event, payload) {
const lstat = await fs.promises.lstat(fullPath);
if (lstat.isSymbolicLink()) {
// Broken symlink
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
result[i] = {
name: brokenEntry.name,
type: "symlink",

View File

@@ -98,6 +98,36 @@ function findDefaultPrivateKey() {
return null;
}
/**
* Find ALL default SSH private keys from user's ~/.ssh directory
* Returns all non-encrypted keys for fallback authentication
* @returns {Array<{ privateKey: string, keyPath: string, keyName: string }>}
*/
function findAllDefaultPrivateKeys() {
const sshDir = path.join(os.homedir(), ".ssh");
const keys = [];
log("Searching for ALL default SSH keys", { sshDir, keyNames: DEFAULT_KEY_NAMES });
for (const name of DEFAULT_KEY_NAMES) {
const keyPath = path.join(sshDir, name);
if (fs.existsSync(keyPath)) {
try {
const privateKey = fs.readFileSync(keyPath, "utf8");
const encrypted = isKeyEncrypted(privateKey);
if (!encrypted) {
keys.push({ privateKey, keyPath, keyName: name });
log("Found default key for fallback", { keyPath, keyName: name });
} else {
log("Skipping encrypted key", { keyPath, keyName: name });
}
} catch (e) {
log("Failed to read key", { keyPath, error: e.message });
}
}
}
log("Found default SSH keys", { count: keys.length, keyNames: keys.map(k => k.keyName) });
return keys;
}
/**
* Check if Windows SSH Agent service is running
* @returns {Promise<{ running: boolean, startupType: string | null, error: string | null }>}
@@ -449,22 +479,37 @@ async function startSSHSession(event, options) {
connectOpts.password = options.password;
}
// Always try to find default SSH key for fallback authentication
// Always try to find default SSH keys for fallback authentication
// This allows fallback even when password auth fails
let defaultKeyInfo = null;
let allDefaultKeys = [];
let usedDefaultKeyAsPrimary = false;
const defaultKey = findDefaultPrivateKey();
if (defaultKey) {
defaultKeyInfo = defaultKey;
log("Found default SSH key for fallback", { keyPath: defaultKey.keyPath, keyName: defaultKey.keyName });
}
// Also find ALL default keys for comprehensive fallback
allDefaultKeys = findAllDefaultPrivateKeys();
// If no primary auth method configured, use default key as primary
// If no primary auth method configured, try ssh-agent first, then ALL default keys
if (!connectOpts.privateKey && !connectOpts.password && !connectOpts.agent) {
log("No auth method configured, using default SSH key as primary auth");
if (defaultKeyInfo) {
connectOpts.privateKey = defaultKeyInfo.privateKey;
usedDefaultKeyAsPrimary = true; // Track that we promoted default key to primary
// First, try to use ssh-agent if available (this is what regular SSH does)
const sshAgentSocket = process.platform === "win32"
? "\\\\.\\pipe\\openssh-ssh-agent"
: process.env.SSH_AUTH_SOCK;
if (sshAgentSocket) {
log("No auth method configured, trying ssh-agent first", { agentSocket: sshAgentSocket });
connectOpts.agent = sshAgentSocket;
}
// Mark that we need to try all default keys (handled in authMethods below)
if (allDefaultKeys.length > 0) {
log("Will try all default SSH keys as fallback", { count: allDefaultKeys.length, keyNames: allDefaultKeys.map(k => k.keyName) });
// Set first key for connectOpts.privateKey (required for ssh2 to allow publickey auth)
connectOpts.privateKey = allDefaultKeys[0].privateKey;
usedDefaultKeyAsPrimary = true;
} else {
log("No default SSH key found in ~/.ssh directory");
}
@@ -515,25 +560,33 @@ async function startSSHSession(event, options) {
const authMethods = [];
// First try user-configured key if available (explicit user choice)
if (connectOpts.privateKey) {
if (connectOpts.privateKey && !usedDefaultKeyAsPrimary) {
authMethods.push({ type: "publickey", key: connectOpts.privateKey, passphrase: connectOpts.passphrase, id: "publickey-user" });
}
// Then try password if available (explicit user choice)
// Password before agent because agent may be auto-set via SSH_AUTH_SOCK
// and on servers with low MaxAuthTries, agent attempt could exhaust tries
if (connectOpts.password) {
authMethods.push({ type: "password", id: "password" });
}
// Then try agent if configured (agentForwarding or SSH_AUTH_SOCK)
// Agent after password since it may be auto-configured rather than explicit
// Then try agent if configured (try agent before password since it's usually faster)
if (connectOpts.agent) {
authMethods.push({ type: "agent", id: "agent" });
}
// Then try default SSH key as fallback (if not already used as primary)
if (defaultKeyInfo && !options.privateKey && !usedDefaultKeyAsPrimary) {
// Then try password if available (explicit user choice)
if (connectOpts.password) {
authMethods.push({ type: "password", id: "password" });
}
// Then try ALL default SSH keys as fallback (not just the first one!)
// This is critical because different servers may have different keys in authorized_keys
if (usedDefaultKeyAsPrimary && allDefaultKeys.length > 0) {
for (const keyInfo of allDefaultKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
isDefault: true,
id: `publickey-default-${keyInfo.keyName}`
});
}
} else if (defaultKeyInfo && !options.privateKey && !usedDefaultKeyAsPrimary) {
// Single default key fallback (when user has configured other auth methods)
authMethods.push({ type: "publickey", key: defaultKeyInfo.privateKey, isDefault: true, id: "publickey-default" });
}
@@ -542,7 +595,8 @@ async function startSSHSession(event, options) {
log("Auth methods configured", {
methods: authMethods.map(m => ({ type: m.type, id: m.id, isDefault: m.isDefault || false })),
cachedMethod
cachedMethod,
usedDefaultKeyAsPrimary
});
// Reorder methods based on cached successful method
@@ -959,17 +1013,30 @@ async function startSSHSession(event, options) {
}
} else if (typeof connectOpts.authHandler !== "function") {
// Create authHandler with keyboard-interactive support
// This path is taken when usedDefaultKeyAsPrimary=true (only keyboard-interactive in authMethods)
// Using array format is more reliable - ssh2 uses connectOpts credentials directly
const authMethods = [];
// Try agent FIRST (this is what regular SSH does - it checks ssh-agent before key files)
if (connectOpts.agent) authMethods.push("agent");
if (connectOpts.privateKey) authMethods.push("publickey");
if (connectOpts.password) authMethods.push("password");
authMethods.push("keyboard-interactive");
connectOpts.authHandler = authMethods;
log("Using simple array authHandler", { authMethods, usedDefaultKeyAsPrimary });
}
// If authHandler is a function, it already handles keyboard-interactive
// Increase timeout to allow for keyboard-interactive auth
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
// Enable debug logging for ssh2 to diagnose auth issues
connectOpts.debug = (msg) => {
// Only log auth-related messages to avoid noise
if (msg.includes('Auth') || msg.includes('auth') || msg.includes('publickey') || msg.includes('keyboard')) {
log("ssh2 debug", { msg });
}
};
console.log(`${logPrefix} Connecting to ${options.hostname}...`);
conn.connect(connectOpts);
});