Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a451fd8811 | ||
|
|
49cef792a8 | ||
|
|
62511ceb21 | ||
|
|
00cbb05d71 | ||
|
|
3497614165 | ||
|
|
b652b836a7 |
@@ -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',
|
||||
|
||||
@@ -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': '已下载',
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user