Compare commits

...

6 Commits

Author SHA1 Message Date
陈大猫
a451fd8811 Merge pull request #308 from binaricat/fix/issue-307-display-upload-path
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix(sftp): display upload destination path on completed task items (#307)
2026-03-10 21:26:06 +08:00
bincxz
49cef792a8 fix(sftp): display upload destination path on completed task items (#307)
Show the remote target path inline on completed upload task items
(e.g. "Completed - 1.2 MB → /home/user/dir") so users know exactly
where their files were uploaded after drag-and-drop to terminal.

- Add `targetPath` field to modal's TransferTask type
- Populate targetPath from currentPath in onTaskCreated callback
- Display targetPath on completed upload items in SftpModalUploadTasks
- Add i18n key `sftp.upload.completedToPath` (en/zh-CN)
2026-03-10 21:14:25 +08:00
陈大猫
62511ceb21 Merge pull request #305 from binaricat/fix/sftp-mfa-auth-304
fix(sftp): handle non-fatal agent auth errors for MFA/keyboard-interactive (#304)
2026-03-10 10:54:37 +08:00
bincxz
00cbb05d71 fix(sftp): handle end/close events during SSH connect phase
Address code review feedback: the direct ssh2.Client connect path
was missing end/close event handlers. If the server closes the
connection before 'ready' (e.g. rejected handshake, hop drops),
the promise now properly rejects instead of hanging forever.

Uses a settle/cleanup pattern to ensure listeners are removed and
the promise is resolved/rejected exactly once.
2026-03-10 10:40:47 +08:00
bincxz
3497614165 fix(sftp): fallback to standard SFTP when sudo sftp-server not found
When sudo SFTP fails with exit code 127 (sftp-server binary not found,
e.g. on ESXi), automatically fall back to the standard SFTP subsystem
channel instead of failing the entire connection. This avoids requiring
users to manually disable sudo mode for hosts that lack sftp-server.
2026-03-10 10:37:47 +08:00
bincxz
b652b836a7 fix(sftp): handle non-fatal agent auth errors for MFA/keyboard-interactive (#304)
Two compounding issues caused SFTP connections to fail when
keyboard-interactive (MFA) authentication was required:

1. ssh2-sftp-client's connect() installs error listeners that reject
   the entire connection on ANY error, including non-fatal agent auth
   failures. This prevents ssh2 from falling through to
   keyboard-interactive. Fix: bypass ssh2-sftp-client's connect() and
   use direct ssh2.Client with err.level === 'agent' filtering.

2. getSshAgentSocket() on Windows unconditionally returned the agent
   pipe path without checking if the SSH Agent service is running.
   Fix: added async getAvailableAgentSocket() that runs
   'sc query ssh-agent' before returning the pipe path.
2026-03-10 10:12:37 +08:00
6 changed files with 157 additions and 36 deletions

View File

@@ -728,6 +728,7 @@ const en: Messages = {
'sftp.upload.currentFile': 'Current: {fileName}',
'sftp.upload.cancelled': 'Upload cancelled',
'sftp.upload.cancel': 'Cancel',
'sftp.upload.completedToPath': 'Uploaded to {path}',
// SFTP Download
'sftp.download.completed': 'Downloaded',

View File

@@ -1053,6 +1053,7 @@ const zhCN: Messages = {
'sftp.upload.currentFile': '当前: {fileName}',
'sftp.upload.cancelled': '上传已取消',
'sftp.upload.cancel': '取消',
'sftp.upload.completedToPath': '已上传至 {path}',
// SFTP Download
'sftp.download.completed': '已下载',

View File

@@ -13,6 +13,7 @@ interface TransferTask {
status: "pending" | "uploading" | "downloading" | "completed" | "failed" | "cancelled";
error?: string;
direction: "upload" | "download";
targetPath?: string;
}
interface SftpModalUploadTasksProps {
@@ -166,6 +167,9 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
{task.status === "completed" && (
<div className="text-[10px] text-green-600 mt-0.5">
{t(task.direction === "download" ? "sftp.download.completed" : "sftp.upload.completed")} - {formatBytes(task.totalBytes)}
{task.targetPath && (
<span className="text-muted-foreground ml-1"> {task.targetPath}</span>
)}
</div>
)}
{task.status === "cancelled" && (

View File

@@ -27,6 +27,7 @@ interface TransferTask {
fileCount?: number;
completedCount?: number;
direction: "upload" | "download";
targetPath?: string;
}
// Keep UploadTask as alias for backwards compatibility
@@ -246,6 +247,7 @@ export const useSftpModalTransfers = ({
startTime: Date.now(),
isDirectory: task.isDirectory,
direction: "upload",
targetPath: currentPath,
};
setUploadTasks(prev => [...prev, uploadTask]);
},
@@ -343,7 +345,7 @@ export const useSftpModalTransfers = ({
);
},
};
}, [t]);
}, [t, currentPath]);
// Helper function to perform upload with compression setting from user preference
const performUpload = useCallback(async (

View File

@@ -29,6 +29,7 @@ const {
applyAuthToConnOpts,
safeSend: authSafeSend,
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
getAvailableAgentSocket,
} = require("./sshAuthHelper.cjs");
// SFTP clients storage - shared reference passed from main
@@ -427,7 +428,7 @@ function init(deps) {
/**
* Connect through a chain of jump hosts for SFTP
*/
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort, connId) {
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort, connId, agentSocket) {
const sender = event.sender;
const connections = [];
let currentSocket = null;
@@ -498,6 +499,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
logPrefix: `[SFTP Chain] Hop ${i + 1}`,
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
defaultKeys,
sshAgentSocketOverride: agentSocket,
});
applyAuthToConnOpts(connOpts, authConfig);
@@ -521,6 +523,11 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
resolve();
});
conn.on('error', (err) => {
// Filter out non-fatal agent auth errors (same as in openSftp)
if (err.level === 'agent') {
console.log(`[SFTP Chain] Hop ${i + 1} non-fatal agent auth error (will try next method):`, err.message);
return;
}
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} error:`, err.message);
reject(err);
});
@@ -828,6 +835,10 @@ async function openSftp(event, options) {
let chainConnections = [];
let connectionSocket = null;
// Pre-fetch agent socket (async check for Windows SSH Agent service)
// This is used by both jump host chain auth and final host auth
const agentSocket = await getAvailableAgentSocket();
// Handle chain/proxy connections
if (hasJumpHosts) {
console.log(`[SFTP] Opening connection through ${jumpHosts.length} jump host(s) to ${options.hostname}:${options.port || 22}`);
@@ -841,7 +852,8 @@ async function openSftp(event, options) {
jumpHosts,
options.hostname,
options.port || 22,
connId
connId,
agentSocket
);
connectionSocket = chainResult.socket;
chainConnections = chainResult.connections;
@@ -895,6 +907,7 @@ async function openSftp(event, options) {
if (options.password) connectOpts.password = options.password;
// Build auth handler using shared helper
// Use pre-fetched agentSocket (validated async, including Windows service check)
const authConfig = buildAuthHandler({
privateKey: connectOpts.privateKey,
password: connectOpts.password,
@@ -903,6 +916,7 @@ async function openSftp(event, options) {
username: connectOpts.username,
logPrefix: "[SFTP]",
defaultKeys,
sshAgentSocketOverride: agentSocket,
});
applyAuthToConnOpts(connectOpts, authConfig);
@@ -922,44 +936,104 @@ async function openSftp(event, options) {
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
try {
if (options.sudo) {
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
const sshClient = client.client;
// IMPORTANT: We bypass ssh2-sftp-client's connect() method and use the
// underlying ssh2 Client directly. This is because ssh2-sftp-client adds
// temporary error listeners that reject the entire connect promise on ANY
// error, including non-fatal auth errors (e.g. 'Failed to connect to agent'
// when ssh2 tries agent auth and falls through to the next method).
// By connecting directly, we can filter these non-fatal errors and allow
// the auth flow to continue to keyboard-interactive/password/etc.
const sshClient = client.client;
await new Promise((resolve, reject) => {
// Set up error handler for initial connection
const onConnectError = (err) => reject(err);
sshClient.once('error', onConnectError);
await new Promise((resolve, reject) => {
let settled = false;
const settle = (fn, val) => {
if (settled) return;
settled = true;
cleanup();
fn(val);
};
sshClient.once('ready', async () => {
sshClient.removeListener('error', onConnectError);
try {
// Use provided password or try empty if using key auth (and hope for nopasswd sudo)
const sudoPass = options.password || "";
const sftpWrapper = await connectSudoSftp(sshClient, sudoPass);
const onError = (err) => {
// Filter out non-fatal authentication errors.
// ssh2 sets err.level = 'agent' when agent auth fails — it then
// internally calls tryNextAuth() to proceed with the next method.
// We must NOT reject here, or the fallback won't execute.
if (err.level === 'agent') {
console.log('[SFTP] Non-fatal agent auth error (will try next method):', err.message);
return;
}
settle(reject, err);
};
// Inject into sftp-client
client.sftp = sftpWrapper;
const onEnd = () => {
settle(reject, new Error('Connection closed before SFTP session was ready'));
};
// Important: attach cleanup listener expected by sftp-client
client.sftp.on('close', () => client.end());
const onClose = () => {
settle(reject, new Error('Connection closed before SFTP session was ready'));
};
const cleanup = () => {
sshClient.removeListener('error', onError);
sshClient.removeListener('end', onEnd);
sshClient.removeListener('close', onClose);
};
sshClient.on('error', onError);
sshClient.on('end', onEnd);
sshClient.on('close', onClose);
sshClient.once('ready', () => {
cleanup();
if (options.sudo) {
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
(async () => {
try {
const sudoPass = options.password || "";
const sftpWrapper = await connectSudoSftp(sshClient, sudoPass);
client.sftp = sftpWrapper;
client.sftp.on('close', () => client.end());
resolve();
} catch (e) {
// Fallback: if sftp-server binary is missing (exit code 127),
// try standard SFTP subsystem instead of failing completely.
// This handles systems like ESXi that don't have sftp-server
// but support the SFTP subsystem natively.
if (e.message && e.message.includes('exit code 127')) {
console.warn('[SFTP] sftp-server not found, falling back to standard SFTP subsystem');
options.sudo = false; // Mark as non-sudo for downstream logic
sshClient.sftp((sftpErr, sftp) => {
if (sftpErr) {
sshClient.end();
return reject(sftpErr);
}
client.sftp = sftp;
resolve();
});
} else {
sshClient.end();
reject(e);
}
}
})();
} else {
// Open standard SFTP subsystem channel
sshClient.sftp((err, sftp) => {
if (err) return reject(err);
client.sftp = sftp;
resolve();
} catch (e) {
sshClient.end();
reject(e);
}
});
try {
sshClient.connect(connectOpts);
} catch (e) {
reject(e);
});
}
});
} else {
await client.connect(connectOpts);
}
try {
sshClient.connect(connectOpts);
} catch (e) {
settle(reject, e);
}
});
// Increase max listeners AFTER connect, when the internal ssh2 Client exists
// This prevents Node.js MaxListenersExceededWarning when performing many operations
// ssh2-sftp-client adds temporary listeners for each operation, so we need a high limit

View File

@@ -6,6 +6,7 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { exec } = require("node:child_process");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
@@ -123,11 +124,33 @@ async function findAllDefaultPrivateKeys(options = {}) {
}
/**
* Get ssh-agent socket path based on platform
* Check if Windows SSH Agent service is running
* @returns {Promise<boolean>}
*/
function checkWindowsSshAgentRunning() {
return new Promise((resolve) => {
if (process.platform !== "win32") {
resolve(true);
return;
}
exec("sc query ssh-agent", (err, stdout) => {
if (err) {
resolve(false);
return;
}
resolve(stdout.includes("RUNNING"));
});
});
}
/**
* Get ssh-agent socket path based on platform (synchronous, best-effort)
* @returns {string|null}
*/
function getSshAgentSocket() {
if (process.platform === "win32") {
// On Windows, always return the pipe path; the caller should use
// getAvailableAgentSocket() for a reliable async check.
return "\\\\.\\pipe\\openssh-ssh-agent";
}
const agentSocket = process.env.SSH_AUTH_SOCK;
@@ -143,6 +166,18 @@ function getSshAgentSocket() {
}
}
/**
* Get ssh-agent socket path with async validation (checks Windows service status)
* @returns {Promise<string|null>}
*/
async function getAvailableAgentSocket() {
if (process.platform === "win32") {
const running = await checkWindowsSshAgentRunning();
return running ? "\\\\.\\pipe\\openssh-ssh-agent" : null;
}
return getSshAgentSocket();
}
/**
* Build authentication handler with default key fallback support
* @param {Object} options
@@ -156,7 +191,7 @@ function getSshAgentSocket() {
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
*/
function buildAuthHandler(options) {
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [] } = options;
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [], sshAgentSocketOverride } = options;
// Determine what type of explicit auth the user configured
const hasExplicitKey = !!privateKey;
@@ -168,7 +203,10 @@ function buildAuthHandler(options) {
const isPasswordOnly = hasExplicitPassword && !hasExplicitKey && !hasExplicitAgent;
const isKeyOnly = hasExplicitKey && !hasExplicitAgent;
const sshAgentSocket = getSshAgentSocket();
// Allow callers to pass in a pre-validated agent socket (e.g. from async
// getAvailableAgentSocket). Fall back to synchronous getSshAgentSocket()
// which on Windows always returns the pipe path without checking the service.
const sshAgentSocket = sshAgentSocketOverride !== undefined ? sshAgentSocketOverride : getSshAgentSocket();
// Only use system ssh-agent BEFORE user's auth when:
// - User explicitly configured agent, OR
@@ -522,6 +560,7 @@ module.exports = {
findDefaultPrivateKey,
findAllDefaultPrivateKeys,
getSshAgentSocket,
getAvailableAgentSocket,
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,