diff --git a/backend/src/main/java/com/sshmanager/controller/SftpController.java b/backend/src/main/java/com/sshmanager/controller/SftpController.java index eb254bf..d603e0e 100644 --- a/backend/src/main/java/com/sshmanager/controller/SftpController.java +++ b/backend/src/main/java/com/sshmanager/controller/SftpController.java @@ -1,5 +1,6 @@ package com.sshmanager.controller; +import com.jcraft.jsch.JSchException; import com.jcraft.jsch.SftpException; import com.sshmanager.dto.SftpFileInfo; import com.sshmanager.entity.Connection; @@ -7,6 +8,7 @@ import com.sshmanager.entity.User; import com.sshmanager.repository.UserRepository; import com.sshmanager.service.ConnectionService; import com.sshmanager.service.SftpService; +import com.sshmanager.util.JschUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -150,16 +152,19 @@ public class SftpController { } private String toSftpErrorMessage(Exception e, String path, String operation) { - if (e.getMessage() != null && !e.getMessage().trim().isEmpty()) { - return e.getMessage(); - } Throwable cur = e; for (int i = 0; i < 10 && cur != null; i++) { + // SFTP protocol errors → human-readable format if (cur instanceof SftpException) { return SftpService.formatSftpExceptionMessage((SftpException) cur, path, operation); } - if (cur.getMessage() != null && !cur.getMessage().trim().isEmpty()) { - return cur.getMessage(); + String msg = cur.getMessage(); + if (msg != null && !msg.trim().isEmpty()) { + // Auth failure → translated message + if (cur instanceof JSchException || msg.contains("Auth fail") || msg.contains("authentication")) { + return JschUtil.formatAuthErrorMessage(msg); + } + return msg; } cur = cur.getCause(); } @@ -247,9 +252,10 @@ public class SftpController { } }); } catch (Exception e) { - log.warn("SFTP pwd failed: connectionId={}", connectionId, e); + String errorMsg = toSftpErrorMessage(e, null, "pwd"); + log.warn("SFTP pwd failed: connectionId={}, error={}", connectionId, errorMsg, e); Map err = new HashMap<>(); - err.put("error", e.getMessage() != null ? e.getMessage() : "pwd failed"); + err.put("error", errorMsg); return ResponseEntity.status(500).body(err); } } @@ -461,8 +467,9 @@ public class SftpController { } }); } catch (Exception e) { + String errorMsg = toSftpErrorMessage(e, null, "delete"); Map error = new HashMap<>(); - error.put("error", e.getMessage()); + error.put("error", errorMsg); return ResponseEntity.status(500).body(error); } } @@ -491,8 +498,9 @@ public class SftpController { } }); } catch (Exception e) { + String errorMsg = toSftpErrorMessage(e, null, "mkdir"); Map error = new HashMap<>(); - error.put("error", e.getMessage()); + error.put("error", errorMsg); return ResponseEntity.status(500).body(error); } } @@ -522,8 +530,9 @@ public class SftpController { } }); } catch (Exception e) { + String errorMsg = toSftpErrorMessage(e, null, "rename"); Map error = new HashMap<>(); - error.put("error", e.getMessage()); + error.put("error", errorMsg); return ResponseEntity.status(500).body(error); } } @@ -548,8 +557,9 @@ public class SftpController { result.put("message", "Transferred"); return ResponseEntity.ok(result); } catch (Exception e) { + String errorMsg = toSftpErrorMessage(e, null, "transfer"); Map error = new HashMap<>(); - error.put("error", e.getMessage() != null ? e.getMessage() : "Transfer failed"); + error.put("error", errorMsg); return ResponseEntity.status(500).body(error); } } diff --git a/backend/src/main/java/com/sshmanager/service/SftpService.java b/backend/src/main/java/com/sshmanager/service/SftpService.java index bb635b7..92ef06c 100644 --- a/backend/src/main/java/com/sshmanager/service/SftpService.java +++ b/backend/src/main/java/com/sshmanager/service/SftpService.java @@ -7,13 +7,13 @@ import com.jcraft.jsch.SftpATTRS; import com.jcraft.jsch.SftpException; import com.jcraft.jsch.SftpProgressMonitor; import com.sshmanager.entity.Connection; +import com.sshmanager.util.JschUtil; import org.springframework.stereotype.Service; import java.io.InputStream; import java.io.OutputStream; import java.io.PipedInputStream; import java.io.PipedOutputStream; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Vector; @@ -33,29 +33,7 @@ public class SftpService { public SftpSession connect(Connection conn, String password, String privateKey, String passphrase) throws Exception { - JSch jsch = new JSch(); - - if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) { - byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8); - byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty()) - ? passphrase.getBytes(StandardCharsets.UTF_8) : null; - jsch.addIdentity("key", keyBytes, null, passphraseBytes); - } - - Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort()); - session.setConfig("StrictHostKeyChecking", "no"); - // Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE - session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1"); - if (conn.getAuthType() == Connection.AuthType.PASSWORD) { - if (password == null || password.isEmpty()) { - throw new IllegalArgumentException("Password is required for password authentication"); - } - session.setConfig("PreferredAuthentications", "password"); - session.setPassword(password); - } else { - session.setConfig("PreferredAuthentications", "publickey"); - } - session.connect(10000); + Session session = JschUtil.createSession(conn, password, privateKey, passphrase); ChannelSftp channel = (ChannelSftp) session.openChannel("sftp"); channel.connect(5000); diff --git a/backend/src/main/java/com/sshmanager/service/SshService.java b/backend/src/main/java/com/sshmanager/service/SshService.java index 97e59ab..90be5cf 100644 --- a/backend/src/main/java/com/sshmanager/service/SshService.java +++ b/backend/src/main/java/com/sshmanager/service/SshService.java @@ -1,10 +1,10 @@ -package com.sshmanager.service; - -import com.jcraft.jsch.ChannelExec; -import com.jcraft.jsch.ChannelShell; -import com.jcraft.jsch.JSch; -import com.jcraft.jsch.Session; +package com.sshmanager.service; + +import com.jcraft.jsch.ChannelExec; +import com.jcraft.jsch.ChannelShell; +import com.jcraft.jsch.Session; import com.sshmanager.entity.Connection; +import com.sshmanager.util.JschUtil; import org.springframework.stereotype.Service; import java.io.ByteArrayOutputStream; @@ -13,63 +13,39 @@ import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.io.InputStream; import java.io.OutputStream; -import java.io.PipedInputStream; +import java.io.PipedInputStream; import java.io.PipedOutputStream; - -@Service + +@Service public class SshService { - public SshSession createShellSession(Connection conn, String password, String privateKey, String passphrase) - throws Exception { - JSch jsch = new JSch(); - - if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) { - byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8); - byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty()) - ? passphrase.getBytes(StandardCharsets.UTF_8) : null; - jsch.addIdentity("key", keyBytes, null, passphraseBytes); - } - - Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort()); - session.setConfig("StrictHostKeyChecking", "no"); - // Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE - session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1"); - - if (conn.getAuthType() == Connection.AuthType.PASSWORD) { - if (password == null || password.isEmpty()) { - throw new IllegalArgumentException("Password is required for password authentication"); - } - session.setConfig("PreferredAuthentications", "password"); - session.setPassword(password); - } else { - session.setConfig("PreferredAuthentications", "publickey"); - } - - session.connect(10000); - - ChannelShell channel = (ChannelShell) session.openChannel("shell"); - channel.setPtyType("xterm"); - channel.connect(5000); - - PipedInputStream pipedIn = new PipedInputStream(); - PipedOutputStream pipeToChannel = new PipedOutputStream(pipedIn); - OutputStream channelIn = channel.getOutputStream(); - InputStream channelOut = channel.getInputStream(); - - new Thread(() -> { - try { - byte[] buf = new byte[1024]; - int n; - while ((n = pipedIn.read(buf)) > 0) { - channelIn.write(buf, 0, n); - channelIn.flush(); - } - } catch (Exception e) { - // Channel closed - } - }).start(); - - return new SshSession(session, channel, channelOut, pipeToChannel); + public SshSession createShellSession(Connection conn, String password, String privateKey, String passphrase) + throws Exception { + Session session = JschUtil.createSession(conn, password, privateKey, passphrase); + + ChannelShell channel = (ChannelShell) session.openChannel("shell"); + channel.setPtyType("xterm"); + channel.connect(5000); + + PipedInputStream pipedIn = new PipedInputStream(); + PipedOutputStream pipeToChannel = new PipedOutputStream(pipedIn); + OutputStream channelIn = channel.getOutputStream(); + InputStream channelOut = channel.getInputStream(); + + new Thread(() -> { + try { + byte[] buf = new byte[1024]; + int n; + while ((n = pipedIn.read(buf)) > 0) { + channelIn.write(buf, 0, n); + channelIn.flush(); + } + } catch (Exception e) { + // Channel closed + } + }).start(); + + return new SshSession(session, channel, channelOut, pipeToChannel); } // 执行单次命令并返回输出 @@ -83,25 +59,7 @@ public class SshService { String privateKey, String passphrase, String command) throws Exception { - JSch jsch = new JSch(); - - if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) { - byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8); - byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty()) - ? passphrase.getBytes(StandardCharsets.UTF_8) : null; - jsch.addIdentity("key", keyBytes, null, passphraseBytes); - } - - Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort()); - session.setConfig("StrictHostKeyChecking", "no"); - session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1"); - session.setConfig("PreferredAuthentications", conn.getAuthType() == Connection.AuthType.PASSWORD ? "password" : "publickey"); - - if (conn.getAuthType() == Connection.AuthType.PASSWORD) { - session.setPassword(password); - } - - session.connect(8000); + Session session = JschUtil.createSession(conn, password, privateKey, passphrase); ChannelExec channel = (ChannelExec) session.openChannel("exec"); channel.setCommand(command); @@ -137,22 +95,22 @@ public class SshService { } public static class SshSession { - private final Session session; - private final ChannelShell channel; - private final InputStream outputStream; - private final OutputStream inputStream; - - public SshSession(Session session, ChannelShell channel, InputStream outputStream, OutputStream inputStream) { - this.session = session; - this.channel = channel; - this.outputStream = outputStream; - this.inputStream = inputStream; - } - - public InputStream getOutputStream() { - return outputStream; - } - + private final Session session; + private final ChannelShell channel; + private final InputStream outputStream; + private final OutputStream inputStream; + + public SshSession(Session session, ChannelShell channel, InputStream outputStream, OutputStream inputStream) { + this.session = session; + this.channel = channel; + this.outputStream = outputStream; + this.inputStream = inputStream; + } + + public InputStream getOutputStream() { + return outputStream; + } + public OutputStream getInputStream() { return inputStream; } @@ -169,16 +127,16 @@ public class SshService { } catch (Exception ignored) { } } - + public void disconnect() { if (channel != null && channel.isConnected()) { channel.disconnect(); } - if (session != null && session.isConnected()) { - session.disconnect(); - } - } - + if (session != null && session.isConnected()) { + session.disconnect(); + } + } + public boolean isConnected() { return channel != null && channel.isConnected(); } diff --git a/backend/src/main/java/com/sshmanager/util/JschUtil.java b/backend/src/main/java/com/sshmanager/util/JschUtil.java new file mode 100644 index 0000000..05c4b6d --- /dev/null +++ b/backend/src/main/java/com/sshmanager/util/JschUtil.java @@ -0,0 +1,217 @@ +package com.sshmanager.util; + +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.Session; +import com.jcraft.jsch.UIKeyboardInteractive; +import com.jcraft.jsch.UserInfo; +import com.sshmanager.entity.Connection; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; + +/** + * Shared utility for creating JSch SSH sessions with consistent configuration. + * + * Supports two authentication modes: + *
    + *
  • PASSWORD — sets PreferredAuthentications=password,keyboard-interactive, + * provides UserInfo + UIKeyboardInteractive so keyboard-interactive + * challenges are answered with the same password.
  • + *
  • PRIVATE_KEY — sets PreferredAuthentications=publickey only.
  • + *
+ */ +public final class JschUtil { + + private static final Logger log = LoggerFactory.getLogger(JschUtil.class); + private static final int CONNECT_TIMEOUT = 10000; + + private JschUtil() {} + + /** + * Create and connect an SSH session for the given connection configuration. + * + * @param conn the connection entity (host, port, username, authType) + * @param password decrypted password (may be null for private-key auth) + * @param privateKey decrypted private key PEM (may be null for password auth) + * @param passphrase decrypted passphrase for the private key (may be null) + * @return connected Session + * @throws IllegalArgumentException if required credentials are missing or empty + * @throws Exception if JSch fails to connect (auth failure, timeout, etc.) + */ + public static Session createSession(Connection conn, String password, String privateKey, String passphrase) + throws Exception { + JSch jsch = new JSch(); + + if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY) { + if (privateKey == null || privateKey.isEmpty()) { + throw new IllegalArgumentException( + "SSH authentication failed: private key is empty. Please check the connection key configuration."); + } + byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8); + byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty()) + ? passphrase.getBytes(StandardCharsets.UTF_8) : null; + jsch.addIdentity("key", keyBytes, null, passphraseBytes); + } + + Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort()); + session.setConfig("StrictHostKeyChecking", "no"); + // Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE + session.setConfig("kex", + "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256," + + "diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1"); + + if (conn.getAuthType() == Connection.AuthType.PASSWORD) { + if (password == null || password.isEmpty()) { + throw new IllegalArgumentException( + "SSH authentication failed: password is empty. Please check the connection password configuration."); + } + session.setConfig("PreferredAuthentications", "password,keyboard-interactive"); + session.setPassword(password); + session.setUserInfo(new PasswordAuth(password)); + } else { + session.setConfig("PreferredAuthentications", "publickey"); + } + + try { + session.connect(CONNECT_TIMEOUT); + } catch (Exception e) { + // Sanitized log: connection metadata only, no secrets + log.warn("SSH connection failed: connectionId={}, host={}, port={}, username={}, authType={}, error={}", + conn.getId(), conn.getHost(), conn.getPort(), conn.getUsername(), conn.getAuthType(), + formatAuthErrorMessage(e.getMessage())); + throw e; + } + return session; + } + + /** + * Translate common JSch exception messages into human-readable text. + * Returns the original message if no pattern matches. + */ + public static String formatAuthErrorMessage(String rawMessage) { + if (rawMessage == null) { + return "SSH connection failed (unknown error)"; + } + String msg = rawMessage.trim(); + + // Too many authentication failures — check before generic auth fail + // because "Auth fail for methods ..." may appear in the same chain + // but "too many" is the actionable root cause. + if (msg.toLowerCase().contains("too many authentication failures")) { + return "SSH authentication failed: too many failed attempts, server rejected the connection. " + + "Please check the password or authentication method, or try again later."; + } + + // Auth fail for methods 'publickey,password' + // (may be prefixed with e.g. "com.jcraft.jsch.JSchException: Auth fail ...") + int authFailIdx = msg.indexOf("Auth fail for methods "); + if (authFailIdx >= 0) { + String afterPrefix = msg.substring(authFailIdx + "Auth fail for methods ".length()); + String methods = afterPrefix.contains("'") ? afterPrefix.substring(0, afterPrefix.lastIndexOf('\'') + 1) : afterPrefix; + methods = methods.replace("'", ""); + return "SSH authentication failed: please check username and password / private key. " + + "Server supports authentication methods: " + methods; + } + + // Auth cancel + if (msg.toLowerCase().contains("auth") && msg.toLowerCase().contains("cancel")) { + return "SSH authentication was cancelled by the server. Please check your credentials."; + } + + // Connection refused + if (msg.toLowerCase().contains("connection refused")) { + return "SSH connection refused. Please check that the host address and port are correct and the SSH service is running."; + } + + // Connection timeout + if (msg.toLowerCase().contains("timeout") || msg.toLowerCase().contains("timed out")) { + return "SSH connection timed out. Please check network connectivity and host reachability."; + } + + // Host key verification (should not happen with StrictHostKeyChecking=no, but just in case) + if (msg.toLowerCase().contains("hostkey") || msg.toLowerCase().contains("host key")) { + return "SSH host key verification failed. The server may have changed its host key."; + } + + // Unknown host + if (msg.toLowerCase().contains("unknown host") || msg.toLowerCase().contains("unable to resolve")) { + return "Unable to resolve host address. Please check the hostname."; + } + + // Socket / IO errors + if (msg.toLowerCase().contains("socket") || msg.toLowerCase().contains("i/o error")) { + return "Network I/O error during SSH connection. Please check network connectivity."; + } + + return msg; + } + + /** + * Combines UserInfo and UIKeyboardInteractive so that password-based auth + * also works when the server uses keyboard-interactive (e.g. some OpenSSH + * configurations that prompt for password via keyboard-interactive). + * + * After one password attempt, subsequent prompts return false / empty + * to prevent the same wrong password from being retried until the + * server closes the connection with "Too many authentication failures". + */ + static class PasswordAuth implements UserInfo, UIKeyboardInteractive { + private final String password; + private boolean passwordAttempted = false; + + PasswordAuth(String password) { + this.password = password; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public boolean promptYesNo(String message) { + return true; + } + + @Override + public String getPassphrase() { + return null; + } + + @Override + public boolean promptPassphrase(String message) { + return false; + } + + @Override + public boolean promptPassword(String message) { + // Allow exactly one password prompt; reject retries with the same wrong credential + if (passwordAttempted) return false; + passwordAttempted = true; + return true; + } + + @Override + public void showMessage(String message) { + // No-op + } + + @Override + public String[] promptKeyboardInteractive(String destination, String name, + String instruction, String[] prompt, boolean[] echo) { + String[] result = new String[prompt.length]; + for (int i = 0; i < prompt.length; i++) { + if (echo[i]) { + result[i] = ""; + } else if (!passwordAttempted) { + result[i] = password; + } else { + result[i] = ""; + } + } + passwordAttempted = true; + return result; + } + } +} diff --git a/backend/src/test/java/com/sshmanager/util/JschUtilTest.java b/backend/src/test/java/com/sshmanager/util/JschUtilTest.java new file mode 100644 index 0000000..76df462 --- /dev/null +++ b/backend/src/test/java/com/sshmanager/util/JschUtilTest.java @@ -0,0 +1,145 @@ +package com.sshmanager.util; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class JschUtilTest { + + // ── formatAuthErrorMessage ────────────────────────────────────────── + + @Test + void formatTooManyAuthFailures() { + String raw = "Too many authentication failures"; + String result = JschUtil.formatAuthErrorMessage(raw); + assertTrue(result.contains("too many failed attempts"), "Should detect too many authentication failures"); + } + + @Test + void formatTooManyAuthFailuresPrefixed() { + String raw = "com.jcraft.jsch.JSchException: Too many authentication failures"; + String result = JschUtil.formatAuthErrorMessage(raw); + assertTrue(result.contains("too many failed attempts"), "Should detect prefixed variant"); + } + + @Test + void formatConnectionResetWithTooManyAuth() { + // Connection reset wrapping the real cause — the formatter sees the raw message + // (toSftpErrorMessage in the controller unwraps the cause chain separately) + String raw = "Connection reset"; + String result = JschUtil.formatAuthErrorMessage(raw); + // "Connection reset" by itself does NOT match "too many auth failures" in + // formatAuthErrorMessage alone — the controller's toSftpErrorMessage + // walks the cause chain to find the root JSchException first. + // This test verifies the formatter doesn't crash on unrelated messages. + assertNotNull(result); + } + + @Test + void formatAuthFailPrefixed() { + String raw = "com.jcraft.jsch.JSchException: Auth fail for methods 'publickey,password'"; + String result = JschUtil.formatAuthErrorMessage(raw); + assertTrue(result.contains("authentication failed"), "Should detect auth failure"); + assertTrue(result.contains("publickey,password"), "Should extract supported methods"); + assertFalse(result.contains("JSchException"), "Should not contain raw exception class name"); + } + + @Test + void formatAuthFailPlain() { + String raw = "Auth fail for methods 'password,keyboard-interactive'"; + String result = JschUtil.formatAuthErrorMessage(raw); + assertTrue(result.contains("authentication failed"), "Should detect auth failure"); + assertTrue(result.contains("keyboard-interactive"), "Should extract methods"); + } + + @Test + void formatConnectionRefused() { + String result = JschUtil.formatAuthErrorMessage("Connection refused: connect"); + assertTrue(result.contains("connection refused"), "Should detect connection refused"); + } + + @Test + void formatTimeout() { + String result = JschUtil.formatAuthErrorMessage("connect timed out"); + assertTrue(result.contains("timed out"), "Should detect timeout"); + } + + @Test + void formatUnknownHost() { + String result = JschUtil.formatAuthErrorMessage("unknown host: example.invalid"); + assertTrue(result.contains("resolve host"), "Should detect unknown host"); + } + + @Test + void formatUnrecognizedReturnsOriginal() { + String raw = "Some random error"; + String result = JschUtil.formatAuthErrorMessage(raw); + assertEquals(raw, result, "Unrecognized messages should pass through unchanged"); + } + + @Test + void formatNullReturnsDefault() { + String result = JschUtil.formatAuthErrorMessage(null); + assertTrue(result.contains("unknown error"), "Null input should return a fallback message"); + } + + // ── PasswordAuth behavior ─────────────────────────────────────────── + + @Test + void passwordAuthFirstPromptPasswordReturnsTrue() { + // Use reflection-like approach via the public API: instantiation is package-private + // so we test the behavior indirectly through known contracts + String password = "test-password"; + JschUtil.PasswordAuth auth = new JschUtil.PasswordAuth(password); + + assertTrue(auth.promptPassword("Password:"), "First promptPassword should return true"); + } + + @Test + void passwordAuthSecondPromptPasswordReturnsFalse() { + String password = "test-password"; + JschUtil.PasswordAuth auth = new JschUtil.PasswordAuth(password); + + auth.promptPassword("Password:"); // first call + assertFalse(auth.promptPassword("Password:"), "Second promptPassword should return false"); + } + + @Test + void passwordAuthKeyboardInteractiveFillsPasswordOnce() { + String password = "test-password"; + JschUtil.PasswordAuth auth = new JschUtil.PasswordAuth(password); + + String[] prompt = {"Password:", "OTP:"}; + boolean[] echo = {false, false}; + + // First call — fill non-echo prompts with password + String[] result1 = auth.promptKeyboardInteractive("host", "ssh", "", prompt, echo); + assertEquals(password, result1[0], "First keyboard-interactive should return password"); + assertEquals(password, result1[1], "Should fill all non-echo prompts"); + + // Second call — no more password + String[] result2 = auth.promptKeyboardInteractive("host", "ssh", "", prompt, echo); + assertEquals("", result2[0], "Second keyboard-interactive should return empty"); + assertEquals("", result2[1], "Second call should not fill password"); + } + + @Test + void passwordAuthGetPasswordReturnsOriginal() { + String password = "test-password"; + JschUtil.PasswordAuth auth = new JschUtil.PasswordAuth(password); + assertEquals(password, auth.getPassword()); + } + + @Test + void passwordAuthEchoPromptsAreLeftEmpty() { + String password = "test-password"; + JschUtil.PasswordAuth auth = new JschUtil.PasswordAuth(password); + + String[] prompt = {"Username:", "Password:"}; + boolean[] echo = {true, false}; // username is echo, password is not + + String[] result = auth.promptKeyboardInteractive("host", "ssh", "", prompt, echo); + assertEquals("", result[0], "Echo prompt should be empty"); + assertEquals(password, result[1], "Non-echo prompt should get password"); + } +} diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index 242a2d3..8657c9d 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -6,13 +6,17 @@ export default function Modal({ children, footer, maxWidth = 'max-w-3xl', + open = true, }: { title: string onClose?: () => void children: React.ReactNode footer?: React.ReactNode maxWidth?: string + open?: boolean }) { + if (!open) return null + return (
diff --git a/frontend/src/components/TransferCenterModal.tsx b/frontend/src/components/TransferCenterModal.tsx index 60c84b3..b498511 100644 --- a/frontend/src/components/TransferCenterModal.tsx +++ b/frontend/src/components/TransferCenterModal.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { CheckCircle2, FileUp, @@ -68,6 +68,35 @@ export default function TransferCenterModal({ const [remoteTargetPath, setRemoteTargetPath] = useState('/data/deploy/') const [showBrowser, setShowBrowser] = useState(false) + // ── sync remoteSourceId when connections or statuses change ── + useEffect(() => { + if (connections.length === 0) return + setRemoteSourceId((prev) => { + if (prev > 0 && connections.some((c) => c.id === prev)) { + // Keep current selection if it's online or status is still loading + const st = connectionStatuses[prev] + if (st === 'online' || !st) return prev + // current source is offline/unknown — re-evaluate + const firstOnline = connections.find((c) => connectionStatuses[c.id] === 'online') + return firstOnline?.id ?? prev + } + return connections.find((c) => connectionStatuses[c.id] === 'online')?.id ?? connections[0]?.id ?? 0 + }) + }, [connections, connectionStatuses]) + + // ── derived state ── + const selectedRemoteSource = useMemo( + () => connections.find((c) => c.id === remoteSourceId) ?? null, + [connections, remoteSourceId], + ) + const isRemoteSourceValid = remoteSourceId > 0 && selectedRemoteSource !== null + const isRemoteSourceOnline = isRemoteSourceValid && connectionStatuses[remoteSourceId] === 'online' + + // In remote-distribution mode, exclude the source server from the effective target list + const effectiveTargetIds = tab === 'remote' ? targetIds.filter((id) => id !== remoteSourceId) : targetIds + const remoteStartDisabled = + !isRemoteSourceValid || !isRemoteSourceOnline || effectiveTargetIds.length === 0 || !remoteSourcePath.trim() + if (!open) return null function updateTaskGroup(groupId: string, updater: (group: TransferTaskGroup) => TransferTaskGroup) { @@ -130,7 +159,8 @@ export default function TransferCenterModal({ } async function handleStartRemote() { - if (!remoteSourceId || targetIds.length === 0 || !remoteSourcePath.trim()) return + const targets = effectiveTargetIds + if (!isRemoteSourceValid || !isRemoteSourceOnline || targets.length === 0 || !remoteSourcePath.trim()) return const groupId = String(Date.now()) const sourceName = remoteSourcePath.trim().split('/').filter(Boolean).pop() || remoteSourcePath.trim() const group: TransferTaskGroup = { @@ -140,7 +170,7 @@ export default function TransferCenterModal({ status: 'running', progress: 0, createdAt: new Date().toLocaleTimeString(), - items: targetIds.map((id) => ({ + items: targets.map((id) => ({ id: `${groupId}-${id}`, label: connections.find((item) => item.id === id)?.name || String(id), status: 'queued', @@ -150,7 +180,7 @@ export default function TransferCenterModal({ } onTasksChange((prev) => [group, ...prev]) - targetIds.forEach(async (targetId) => { + targets.forEach(async (targetId) => { const response = await createRemoteTransferTask(remoteSourceId, remoteSourcePath, targetId, remoteTargetPath) const taskId = response.data.taskId updateTaskGroup(groupId, (current) => ({ @@ -339,9 +369,9 @@ export default function TransferCenterModal({