diff --git a/src/main/java/app/termora/CombinedKeyIdentityProvider.java b/src/main/java/app/termora/CombinedKeyIdentityProvider.java new file mode 100644 index 00000000..864d8f13 --- /dev/null +++ b/src/main/java/app/termora/CombinedKeyIdentityProvider.java @@ -0,0 +1,69 @@ +package app.termora; + +import org.apache.sshd.common.keyprovider.KeyIdentityProvider; +import org.apache.sshd.common.session.SessionContext; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.util.*; + +public class CombinedKeyIdentityProvider implements KeyIdentityProvider { + + private final List providers = new ArrayList<>(); + + @Override + public Iterable loadKeys(SessionContext context) { + return () -> new Iterator<>() { + + private final Iterator factories = providers + .iterator(); + private Iterator current; + + private Boolean hasElement; + + @Override + public boolean hasNext() { + if (hasElement != null) { + return hasElement; + } + while (current == null || !current.hasNext()) { + if (factories.hasNext()) { + try { + current = factories.next().loadKeys(context) + .iterator(); + } catch (IOException | GeneralSecurityException e) { + throw new RuntimeException(e); + } + } else { + current = null; + hasElement = Boolean.FALSE; + return false; + } + } + hasElement = Boolean.TRUE; + return true; + } + + @Override + public KeyPair next() { + if ((hasElement == null && !hasNext()) || !hasElement) { + throw new NoSuchElementException(); + } + hasElement = null; + KeyPair result; + try { + result = current.next(); + } catch (NoSuchElementException e) { + result = null; + } + return result; + } + + }; + } + + public void addKeyKeyIdentityProvider(KeyIdentityProvider provider) { + providers.add(Objects.requireNonNull(provider)); + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/HostDialog.kt b/src/main/kotlin/app/termora/HostDialog.kt index a8f552cb..4da42de0 100644 --- a/src/main/kotlin/app/termora/HostDialog.kt +++ b/src/main/kotlin/app/termora/HostDialog.kt @@ -2,7 +2,6 @@ package app.termora import app.termora.actions.AnAction import app.termora.actions.AnActionEvent -import app.termora.keyboardinteractive.TerminalUserInteraction import kotlinx.coroutines.* import kotlinx.coroutines.swing.Swing import org.apache.commons.lang3.exception.ExceptionUtils @@ -103,8 +102,7 @@ class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) { var client: SshClient? = null var session: ClientSession? = null try { - client = SshClients.openClient(host) - client.userInteraction = TerminalUserInteraction(owner) + client = SshClients.openClient(host, this) session = SshClients.openSession(host, client) } finally { session?.close() diff --git a/src/main/kotlin/app/termora/RequestAuthenticationDialog.kt b/src/main/kotlin/app/termora/RequestAuthenticationDialog.kt index e2ca1b4a..1edf8b7d 100644 --- a/src/main/kotlin/app/termora/RequestAuthenticationDialog.kt +++ b/src/main/kotlin/app/termora/RequestAuthenticationDialog.kt @@ -34,8 +34,8 @@ class RequestAuthenticationDialog(owner: Window, host: Host) : DialogWrapper(own pack() size = Dimension(max(380, size.width), size.height) - - setLocationRelativeTo(null) + preferredSize = size + minimumSize = size publicKeyComboBox.renderer = object : DefaultListCellRenderer() { override fun getListCellRendererComponent( @@ -65,6 +65,10 @@ class RequestAuthenticationDialog(owner: Window, host: Host) : DialogWrapper(own } } + if (host.authentication.type != AuthenticationType.No) { + authenticationTypeComboBox.selectedItem = host.authentication.type + } + usernameTextField.text = host.username } diff --git a/src/main/kotlin/app/termora/SFTPPtyTerminalTab.kt b/src/main/kotlin/app/termora/SFTPPtyTerminalTab.kt index 1215b3fb..435d0223 100644 --- a/src/main/kotlin/app/termora/SFTPPtyTerminalTab.kt +++ b/src/main/kotlin/app/termora/SFTPPtyTerminalTab.kt @@ -30,6 +30,7 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal private var lastPasswordReporterDataListener: PasswordReporterDataListener? = null private val sftpCommand get() = Database.getDatabase().sftp.sftpCommand private val defaultDirectory get() = Database.getDatabase().sftp.defaultDirectory + private val owner get() = SwingUtilities.getWindowAncestor(terminalPanel) init { terminalPanel.dropFiles = true @@ -67,7 +68,7 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal ) ) - val sshClient = SshClients.openClient(host).apply { sshClient = this } + val sshClient = SshClients.openClient(host, owner).apply { sshClient = this } val sshSession = SshClients.openSession(host, sshClient).apply { sshSession = this } // 打开通道 diff --git a/src/main/kotlin/app/termora/SSHTerminalTab.kt b/src/main/kotlin/app/termora/SSHTerminalTab.kt index e3c23831..cdbf2496 100644 --- a/src/main/kotlin/app/termora/SSHTerminalTab.kt +++ b/src/main/kotlin/app/termora/SSHTerminalTab.kt @@ -4,7 +4,6 @@ import app.termora.actions.AnActionEvent import app.termora.actions.DataProviders import app.termora.actions.TabReconnectAction import app.termora.addons.zmodem.ZModemPtyConnectorAdaptor -import app.termora.keyboardinteractive.TerminalUserInteraction import app.termora.keymap.KeyShortcut import app.termora.keymap.KeymapManager import app.termora.terminal.ControlCharacters @@ -89,35 +88,8 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : terminal.write("SSH client is opening...\r\n") } - var host = - this.host.copy(authentication = this.host.authentication.copy(), updateDate = System.currentTimeMillis()) val owner = SwingUtilities.getWindowAncestor(terminalPanel) - val client = SshClients.openClient(host).also { sshClient = it } - client.serverKeyVerifier = DialogServerKeyVerifier(owner) - // keyboard interactive - client.userInteraction = TerminalUserInteraction(owner) - - if (host.authentication.type == AuthenticationType.No) { - withContext(Dispatchers.Swing) { - val dialog = RequestAuthenticationDialog(owner, host) - val authentication = dialog.getAuthentication() - host = host.copy( - authentication = authentication, - username = dialog.getUsername(), - updateDate = System.currentTimeMillis(), - ) - // save - if (dialog.isRemembered()) { - HostManager.getInstance().addHost( - tab.host.copy( - authentication = authentication, - username = dialog.getUsername(), updateDate = System.currentTimeMillis(), - ) - ) - } - } - } - + val client = SshClients.openClient(host, owner).also { sshClient = it } val sessionListener = MySessionListener() val channelListener = MyChannelListener() diff --git a/src/main/kotlin/app/termora/SshClients.kt b/src/main/kotlin/app/termora/SshClients.kt index bef56ce1..43ebe204 100644 --- a/src/main/kotlin/app/termora/SshClients.kt +++ b/src/main/kotlin/app/termora/SshClients.kt @@ -1,15 +1,15 @@ package app.termora import app.termora.keyboardinteractive.TerminalUserInteraction +import app.termora.keymgr.KeyManager import app.termora.keymgr.OhKeyPairKeyPairProvider import app.termora.terminal.TerminalSize -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.swing.Swing -import kotlinx.coroutines.withContext import org.apache.commons.io.IOUtils import org.apache.commons.lang3.StringUtils import org.apache.sshd.client.ClientBuilder import org.apache.sshd.client.SshClient +import org.apache.sshd.client.auth.password.PasswordIdentityProvider +import org.apache.sshd.client.auth.password.UserAuthPasswordFactory import org.apache.sshd.client.channel.ChannelShell import org.apache.sshd.client.channel.ClientChannelEvent import org.apache.sshd.client.config.hosts.HostConfigEntry @@ -27,6 +27,7 @@ import org.apache.sshd.common.config.keys.KeyUtils import org.apache.sshd.common.global.KeepAliveHandler import org.apache.sshd.common.kex.BuiltinDHFactories import org.apache.sshd.common.keyprovider.KeyIdentityProvider +import org.apache.sshd.common.session.SessionContext import org.apache.sshd.common.util.net.SshdSocketAddress import org.apache.sshd.core.CoreModuleProperties import org.apache.sshd.server.forward.AcceptAllForwardingFilter @@ -44,6 +45,7 @@ import java.net.Proxy import java.net.SocketAddress import java.nio.file.Path import java.nio.file.Paths +import java.security.KeyPair import java.security.PublicKey import java.time.Duration import java.util.* @@ -57,6 +59,7 @@ object SshClients { val HOST_KEY = AttributeRepository.AttributeKey() private val timeout = Duration.ofSeconds(30) + private val hostManager get() = HostManager.getInstance() private val log by lazy { LoggerFactory.getLogger(SshClients::class.java) } /** @@ -119,16 +122,16 @@ object SshClients { * 打开一个会话 */ fun openSession(host: Host, client: SshClient): ClientSession { - + val h = hostManager.getHost(host.id) ?: host // 如果没有跳板机直接连接 - if (host.options.jumpHosts.isEmpty()) { - return doOpenSession(host, client) + if (h.options.jumpHosts.isEmpty()) { + return doOpenSession(h, client) } val jumpHosts = mutableListOf() val hosts = HostManager.getInstance().hosts().associateBy { it.id } - for (jumpHostId in host.options.jumpHosts) { + for (jumpHostId in h.options.jumpHosts) { val e = hosts[jumpHostId] if (e == null) { if (log.isWarnEnabled) { @@ -140,7 +143,7 @@ object SshClients { } // 最后一跳是目标机器 - jumpHosts.add(host) + jumpHosts.add(h) val sessions = mutableListOf() for (i in 0 until jumpHosts.size) { @@ -187,14 +190,25 @@ object SshClients { entry.hostName = host.host entry.setProperty("Middleware", middleware.toString()) - val session = client.connect(entry) - .verify(timeout).session + val session = client.connect(entry).verify(timeout).session if (host.authentication.type == AuthenticationType.Password) { session.addPasswordIdentity(host.authentication.password) } else if (host.authentication.type == AuthenticationType.PublicKey) { session.keyIdentityProvider = OhKeyPairKeyPairProvider(host.authentication.password) } + val owner = client.properties["owner"] as Window? + if (owner != null) { + val identityProvider = IdentityProvider(host, owner) + session.passwordIdentityProvider = identityProvider + val combinedKeyIdentityProvider = CombinedKeyIdentityProvider() + if (session.keyIdentityProvider != null) { + combinedKeyIdentityProvider.addKeyKeyIdentityProvider(session.keyIdentityProvider) + } + combinedKeyIdentityProvider.addKeyKeyIdentityProvider(identityProvider) + session.keyIdentityProvider = combinedKeyIdentityProvider + } + val verifyTimeout = Duration.ofSeconds(timeout.seconds * 5) if (!session.auth().verify(verifyTimeout).await(verifyTimeout)) { throw SshException("Authentication failed") @@ -241,27 +255,13 @@ object SshClients { return sshdSocketAddress } - suspend fun openClient(host: Host, owner: Window): Pair { - val client = openClient(host) - var myHost = host - withContext(Dispatchers.Swing) { - client.userInteraction = TerminalUserInteraction(owner) - client.serverKeyVerifier = DialogServerKeyVerifier(owner) - // 弹出授权框 - if (host.authentication.type == AuthenticationType.No) { - val dialog = RequestAuthenticationDialog(owner, host) - val authentication = dialog.getAuthentication() - myHost = myHost.copy( - authentication = authentication, - username = dialog.getUsername(), updateDate = System.currentTimeMillis(), - ) - // save - if (dialog.isRemembered()) { - HostManager.getInstance().addHost(myHost) - } - } - } - return client to myHost + fun openClient(host: Host, owner: Window): SshClient { + val h = hostManager.getHost(host.id) ?: host + val client = openClient(h) + client.userInteraction = TerminalUserInteraction(owner) + client.serverKeyVerifier = DialogServerKeyVerifier(owner) + client.properties["owner"] = owner + return client } /** @@ -298,6 +298,28 @@ object SshClients { // JGit 会尝试读取本地的私钥或缓存的私钥 sshClient.keyIdentityProvider = KeyIdentityProvider { mutableListOf() } + // 设置优先级 + if (host.authentication.type == AuthenticationType.PublicKey) { + CoreModuleProperties.PREFERRED_AUTHS.set( + sshClient, + listOf( + UserAuthPasswordFactory.PUBLIC_KEY, + UserAuthPasswordFactory.PASSWORD, + UserAuthPasswordFactory.KB_INTERACTIVE + ).joinToString(",") + ) + } else { + CoreModuleProperties.PREFERRED_AUTHS.set( + sshClient, + listOf( + UserAuthPasswordFactory.PASSWORD, + UserAuthPasswordFactory.PUBLIC_KEY, + UserAuthPasswordFactory.KB_INTERACTIVE + ).joinToString(",") + ) + } + + val heartbeatInterval = max(host.options.heartbeatInterval, 3) CoreModuleProperties.HEARTBEAT_INTERVAL.set(sshClient, Duration.ofSeconds(heartbeatInterval.toLong())) CoreModuleProperties.ALLOW_DHG1_KEX_FALLBACK.set(sshClient, true) @@ -327,71 +349,123 @@ object SshClients { sshClient.start() return sshClient } -} -private class MyDialogServerKeyVerifier(private val owner: Window) : ServerKeyVerifier, ModifiedServerKeyAcceptor { - override fun verifyServerKey( - clientSession: ClientSession, - remoteAddress: SocketAddress, - serverKey: PublicKey - ): Boolean { - return true + private class MyDialogServerKeyVerifier(private val owner: Window) : ServerKeyVerifier, ModifiedServerKeyAcceptor { + override fun verifyServerKey( + clientSession: ClientSession, + remoteAddress: SocketAddress, + serverKey: PublicKey + ): Boolean { + return true + } + + override fun acceptModifiedServerKey( + clientSession: ClientSession?, + remoteAddress: SocketAddress?, + entry: KnownHostEntry?, + expected: PublicKey?, + actual: PublicKey? + ): Boolean { + val result = AtomicBoolean(false) + + SwingUtilities.invokeAndWait { + result.set( + OptionPane.showConfirmDialog( + parentComponent = owner, + message = I18n.getString( + "termora.host.modified-server-key", + remoteAddress.toString().replace("/", StringUtils.EMPTY), + KeyUtils.getKeyType(expected), + KeyUtils.getFingerPrint(expected), + KeyUtils.getKeyType(actual), + KeyUtils.getFingerPrint(actual), + ), + optionType = JOptionPane.OK_CANCEL_OPTION, + messageType = JOptionPane.WARNING_MESSAGE, + ) == JOptionPane.OK_OPTION + ) + } + + return result.get() + } } - override fun acceptModifiedServerKey( - clientSession: ClientSession?, - remoteAddress: SocketAddress?, - entry: KnownHostEntry?, - expected: PublicKey?, - actual: PublicKey? - ): Boolean { - val result = AtomicBoolean(false) - - SwingUtilities.invokeAndWait { - result.set( - OptionPane.showConfirmDialog( - parentComponent = owner, - message = I18n.getString( - "termora.host.modified-server-key", - remoteAddress.toString().replace("/", StringUtils.EMPTY), - KeyUtils.getKeyType(expected), - KeyUtils.getFingerPrint(expected), - KeyUtils.getKeyType(actual), - KeyUtils.getFingerPrint(actual), - ), - optionType = JOptionPane.OK_CANCEL_OPTION, - messageType = JOptionPane.WARNING_MESSAGE, - ) == JOptionPane.OK_OPTION - ) + private class DialogServerKeyVerifier( + owner: Window, + ) : KnownHostsServerKeyVerifier( + MyDialogServerKeyVerifier(owner), + Paths.get(Application.getBaseDataDir().absolutePath, "known_hosts") + ) { + init { + modifiedServerKeyAcceptor = delegateVerifier as ModifiedServerKeyAcceptor } - return result.get() + override fun updateKnownHostsFile( + clientSession: ClientSession?, + remoteAddress: SocketAddress?, + serverKey: PublicKey?, + file: Path?, + knownHosts: Collection? + ): KnownHostEntry? { + if (clientSession is JGitClientSession) { + if (isMiddleware(clientSession)) { + return null + } + } + return super.updateKnownHostsFile(clientSession, remoteAddress, serverKey, file, knownHosts) + } } -} -class DialogServerKeyVerifier( - owner: Window, -) : KnownHostsServerKeyVerifier( - MyDialogServerKeyVerifier(owner), - Paths.get(Application.getBaseDataDir().absolutePath, "known_hosts") -) { - init { - modifiedServerKeyAcceptor = delegateVerifier as ModifiedServerKeyAcceptor - } - override fun updateKnownHostsFile( - clientSession: ClientSession?, - remoteAddress: SocketAddress?, - serverKey: PublicKey?, - file: Path?, - knownHosts: Collection? - ): KnownHostEntry? { - if (clientSession is JGitClientSession) { - if (SshClients.isMiddleware(clientSession)) { - return null + private class IdentityProvider(private val host: Host, private val owner: Window) : PasswordIdentityProvider, + KeyIdentityProvider { + private val asked = AtomicBoolean(false) + private val hostManager get() = HostManager.getInstance() + private val keyManager get() = KeyManager.getInstance() + private var authentication = Authentication.No + + override fun loadPasswords(session: SessionContext): MutableIterable { + val authentication = ask() + if (authentication.type != AuthenticationType.Password) { + return mutableListOf() + } + return mutableListOf(authentication.password) + } + + override fun loadKeys(session: SessionContext): MutableIterable { + val authentication = ask() + if (authentication.type != AuthenticationType.PublicKey) { + return mutableListOf() + } + val ohKeyPair = keyManager.getOhKeyPair(authentication.password) ?: return mutableListOf() + return mutableListOf(OhKeyPairKeyPairProvider.generateKeyPair(ohKeyPair)) + } + + private fun ask(): Authentication { + if (asked.compareAndSet(false, true)) { + askNow() + } + return authentication + } + + private fun askNow() { + if (SwingUtilities.isEventDispatchThread()) { + val dialog = RequestAuthenticationDialog(owner, host) + dialog.setLocationRelativeTo(owner) + authentication = dialog.getAuthentication() + // save + if (dialog.isRemembered()) { + val host = host.copy( + authentication = authentication, + username = dialog.getUsername(), updateDate = System.currentTimeMillis(), + ) + hostManager.addHost(host) + } + } else { + SwingUtilities.invokeAndWait { askNow() } } } - return super.updateKnownHostsFile(clientSession, remoteAddress, serverKey, file, knownHosts) } } + diff --git a/src/main/kotlin/app/termora/keymgr/SSHCopyIdDialog.kt b/src/main/kotlin/app/termora/keymgr/SSHCopyIdDialog.kt index f7a3e152..faa52d43 100644 --- a/src/main/kotlin/app/termora/keymgr/SSHCopyIdDialog.kt +++ b/src/main/kotlin/app/termora/keymgr/SSHCopyIdDialog.kt @@ -144,7 +144,7 @@ class SSHCopyIdDialog( } try { - val client = SshClients.openClient(host).apply { myClient = this } + val client = SshClients.openClient(host, this).apply { myClient = this } client.userInteraction = TerminalUserInteraction(owner) val session = SshClients.openSession(host, client).apply { mySession = this } val channel = diff --git a/src/main/kotlin/app/termora/sftp/SFTPFileSystemViewPanel.kt b/src/main/kotlin/app/termora/sftp/SFTPFileSystemViewPanel.kt index 527f6df8..f745f37e 100644 --- a/src/main/kotlin/app/termora/sftp/SFTPFileSystemViewPanel.kt +++ b/src/main/kotlin/app/termora/sftp/SFTPFileSystemViewPanel.kt @@ -112,9 +112,9 @@ class SFTPFileSystemViewPanel( closeIO() try { - val (client, host) = SshClients.openClient(thisHost, SwingUtilities.getWindowAncestor(that)) - this.client = client - val session = SshClients.openSession(host, client).apply { session = this } + val owner = SwingUtilities.getWindowAncestor(that) + val client = SshClients.openClient(thisHost, owner).apply { client = this } + val session = SshClients.openSession(thisHost, client).apply { session = this } fileSystem = SftpClientFactory.instance().createSftpFileSystem(session) session.addCloseFutureListener { onClose() } } catch (e: Exception) {