diff --git a/src/main/kotlin/app/termora/EditHostOptionsPane.kt b/src/main/kotlin/app/termora/EditHostOptionsPane.kt index 19dc6d7..36cab47 100644 --- a/src/main/kotlin/app/termora/EditHostOptionsPane.kt +++ b/src/main/kotlin/app/termora/EditHostOptionsPane.kt @@ -34,6 +34,15 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() { terminalOption.heartbeatIntervalTextField.value = host.options.heartbeatInterval tunnelingOption.tunnelings.addAll(host.tunnelings) + + if (host.options.jumpHosts.isNotEmpty()) { + val hosts = HostManager.getInstance().hosts().associateBy { it.id } + for (id in host.options.jumpHosts) { + jumpHostsOption.jumpHosts.add(hosts[id] ?: continue) + } + } + + jumpHostsOption.filter = { it.id != host.id } } override fun getHost(): Host { diff --git a/src/main/kotlin/app/termora/HostOptionsPane.kt b/src/main/kotlin/app/termora/HostOptionsPane.kt index cbb5591..94ab4b8 100644 --- a/src/main/kotlin/app/termora/HostOptionsPane.kt +++ b/src/main/kotlin/app/termora/HostOptionsPane.kt @@ -12,6 +12,7 @@ import java.awt.* import java.awt.event.* import java.nio.charset.Charset import javax.swing.* +import javax.swing.table.DefaultTableCellRenderer import javax.swing.table.DefaultTableModel @@ -20,12 +21,14 @@ open class HostOptionsPane : OptionsPane() { protected val generalOption = GeneralOption() protected val proxyOption = ProxyOption() protected val terminalOption = TerminalOption() - protected val owner: Window? get() = SwingUtilities.getWindowAncestor(this) + protected val jumpHostsOption = JumpHostsOption() + protected val owner: Window get() = SwingUtilities.getWindowAncestor(this) init { addOption(generalOption) addOption(proxyOption) addOption(tunnelingOption) + addOption(jumpHostsOption) addOption(terminalOption) setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8)) @@ -69,6 +72,7 @@ open class HostOptionsPane : OptionsPane() { env = terminalOption.environmentTextArea.text, startupCommand = terminalOption.startupCommandTextField.text, heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int, + jumpHosts = jumpHostsOption.jumpHosts.map { it.id } ) return Host( @@ -635,6 +639,12 @@ open class HostOptionsPane : OptionsPane() { model.addColumn(I18n.getString("termora.new-host.tunneling.table.destination")) + table.putClientProperty( + FlatClientProperties.STYLE, mapOf( + "showHorizontalLines" to true, + "showVerticalLines" to true, + ) + ) table.autoResizeMode = JTable.AUTO_RESIZE_SUBSEQUENT_COLUMNS table.border = BorderFactory.createEmptyBorder() table.fillsViewportHeight = true @@ -843,4 +853,168 @@ open class HostOptionsPane : OptionsPane() { } } + + protected inner class JumpHostsOption : JPanel(BorderLayout()), Option { + val jumpHosts = mutableListOf() + var filter: (host: Host) -> Boolean = { true } + + private val model = object : DefaultTableModel() { + + override fun isCellEditable(row: Int, column: Int): Boolean { + return false + } + + override fun getRowCount(): Int { + return jumpHosts.size + } + + + override fun getValueAt(row: Int, column: Int): Any { + val host = jumpHosts.getOrNull(row) ?: return StringUtils.EMPTY + return if (column == 0) + host.name + else "${host.host}:${host.port}" + } + } + private val table = JTable(model) + private val addBtn = JButton(I18n.getString("termora.new-host.tunneling.add")) + private val moveUpBtn = JButton(I18n.getString("termora.transport.bookmarks.up")) + private val moveDownBtn = JButton(I18n.getString("termora.transport.bookmarks.down")) + private val deleteBtn = JButton(I18n.getString("termora.new-host.tunneling.delete")) + + init { + initView() + initEvents() + } + + private fun initView() { + val scrollPane = JScrollPane(table) + + model.addColumn(I18n.getString("termora.new-host.general.name")) + model.addColumn(I18n.getString("termora.new-host.general.host")) + + table.putClientProperty( + FlatClientProperties.STYLE, mapOf( + "showHorizontalLines" to true, + "showVerticalLines" to true, + ) + ) + table.autoResizeMode = JTable.AUTO_RESIZE_SUBSEQUENT_COLUMNS + table.setDefaultRenderer( + Any::class.java, + DefaultTableCellRenderer().apply { horizontalAlignment = SwingConstants.CENTER }) + table.fillsViewportHeight = true + scrollPane.border = BorderFactory.createCompoundBorder( + BorderFactory.createEmptyBorder(4, 0, 4, 0), + BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.BorderColor) + ) + table.border = BorderFactory.createEmptyBorder() + + moveUpBtn.isFocusable = false + moveDownBtn.isFocusable = false + deleteBtn.isFocusable = false + moveUpBtn.isEnabled = false + moveDownBtn.isEnabled = false + deleteBtn.isEnabled = false + addBtn.isFocusable = false + + val box = Box.createHorizontalBox() + box.add(addBtn) + box.add(Box.createHorizontalStrut(4)) + box.add(deleteBtn) + box.add(Box.createHorizontalStrut(4)) + box.add(moveUpBtn) + box.add(Box.createHorizontalStrut(4)) + box.add(moveDownBtn) + + add(JLabel("${getTitle()}:"), BorderLayout.NORTH) + add(scrollPane, BorderLayout.CENTER) + add(box, BorderLayout.SOUTH) + } + + private fun initEvents() { + addBtn.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent?) { + val dialog = HostTreeDialog(owner) { host -> + jumpHosts.none { it.id == host.id } && filter.invoke(host) + } + + dialog.setLocationRelativeTo(owner) + dialog.isVisible = true + val hosts = dialog.hosts + if (hosts.isEmpty()) { + return + } + hosts.forEach { + val rowCount = model.rowCount + jumpHosts.add(it) + model.fireTableRowsInserted(rowCount, rowCount + 1) + } + } + }) + + deleteBtn.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + val rows = table.selectedRows.sortedDescending() + if (rows.isEmpty()) return + for (row in rows) { + model.removeRow(row) + } + } + }) + + table.selectionModel.addListSelectionListener { + deleteBtn.isEnabled = table.selectedRowCount > 0 + moveUpBtn.isEnabled = deleteBtn.isEnabled && !table.selectedRows.contains(0) + moveDownBtn.isEnabled = deleteBtn.isEnabled && !table.selectedRows.contains(table.rowCount - 1) + } + + + moveUpBtn.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + val rows = table.selectedRows.sorted() + if (rows.isEmpty()) return + + table.clearSelection() + + for (row in rows) { + val host = jumpHosts[(row)] + jumpHosts.removeAt(row) + jumpHosts.add(row - 1, host) + table.addRowSelectionInterval(row - 1, row - 1) + } + } + }) + + moveDownBtn.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + val rows = table.selectedRows.sortedDescending() + if (rows.isEmpty()) return + + table.clearSelection() + + for (row in rows) { + val host = jumpHosts[(row)] + jumpHosts.removeAt(row) + jumpHosts.add(row + 1, host) + table.addRowSelectionInterval(row + 1, row + 1) + } + } + }) + } + + override fun getIcon(isSelected: Boolean): Icon { + return Icons.server + } + + override fun getTitle(): String { + return I18n.getString("termora.new-host.jump-hosts") + } + + override fun getJComponent(): JComponent { + return this + } + + + } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/HostTreeDialog.kt b/src/main/kotlin/app/termora/HostTreeDialog.kt index fd4d2c0..b7495ae 100644 --- a/src/main/kotlin/app/termora/HostTreeDialog.kt +++ b/src/main/kotlin/app/termora/HostTreeDialog.kt @@ -9,7 +9,10 @@ import java.awt.event.WindowEvent import javax.swing.* import javax.swing.tree.TreeSelectionModel -class HostTreeDialog(owner: Window) : DialogWrapper(owner) { +class HostTreeDialog( + owner: Window, + private val filter: (host: Host) -> Boolean = { true } +) : DialogWrapper(owner) { private val tree = HostTree() diff --git a/src/main/kotlin/app/termora/SshClients.kt b/src/main/kotlin/app/termora/SshClients.kt index 6ad4b28..7185efd 100644 --- a/src/main/kotlin/app/termora/SshClients.kt +++ b/src/main/kotlin/app/termora/SshClients.kt @@ -5,10 +5,12 @@ import app.termora.terminal.TerminalSize import org.apache.sshd.client.ClientBuilder import org.apache.sshd.client.SshClient import org.apache.sshd.client.channel.ChannelShell +import org.apache.sshd.client.config.hosts.HostConfigEntryResolver import org.apache.sshd.client.session.ClientSession import org.apache.sshd.common.SshException import org.apache.sshd.common.channel.PtyChannelConfiguration import org.apache.sshd.common.global.KeepAliveHandler +import org.apache.sshd.common.util.net.SshdSocketAddress import org.apache.sshd.core.CoreModuleProperties import org.apache.sshd.server.forward.AcceptAllForwardingFilter import org.apache.sshd.server.forward.RejectAllForwardingFilter @@ -16,14 +18,15 @@ import org.eclipse.jgit.internal.transport.sshd.JGitSshClient import org.eclipse.jgit.transport.CredentialsProvider import org.eclipse.jgit.transport.sshd.IdentityPasswordProvider import org.eclipse.jgit.transport.sshd.ProxyData +import org.slf4j.LoggerFactory import java.net.InetSocketAddress import java.net.Proxy import java.time.Duration import kotlin.math.max -import org.apache.sshd.client.config.hosts.HostConfigEntryResolver object SshClients { private val timeout = Duration.ofSeconds(30) + private val log by lazy { LoggerFactory.getLogger(SshClients::class.java) } /** * 打开一个 Shell @@ -57,6 +60,54 @@ object SshClients { * 打开一个会话 */ fun openSession(host: Host, client: SshClient): ClientSession { + + + // 如果没有跳板机直接连接 + if (host.options.jumpHosts.isEmpty()) { + return doOpenSession(host, client) + } + + val jumpHosts = mutableListOf() + val hosts = HostManager.getInstance().hosts().associateBy { it.id } + for (jumpHostId in host.options.jumpHosts) { + val e = hosts[jumpHostId] + if (e == null) { + if (log.isWarnEnabled) { + log.warn("Failed to find jump host: $jumpHostId") + } + continue + } + jumpHosts.add(e) + } + + // 最后一跳是目标机器 + jumpHosts.add(host) + + val sessions = mutableListOf() + for (i in 0 until jumpHosts.size) { + val currentHost = jumpHosts[i] + sessions.add(doOpenSession(currentHost, client)) + + // 如果有下一跳 + if (i < jumpHosts.size - 1) { + val nextHost = jumpHosts[i + 1] + // 通过 currentHost 的 Session 将远程端口映射到本地 + val address = sessions.last().startLocalPortForwarding( + SshdSocketAddress.LOCALHOST_ADDRESS, + SshdSocketAddress(nextHost.host, nextHost.port), + ) + if (log.isInfoEnabled) { + log.info("jump host: ${currentHost.host}:${currentHost.port} , next host: ${nextHost.host}:${nextHost.port} , local address: ${address.hostName}:${address.port}") + } + // 映射完毕之后修改Host和端口 + jumpHosts[i + 1] = nextHost.copy(host = address.hostName, port = address.port) + } + } + + return sessions.last() + } + + private fun doOpenSession(host: Host, client: SshClient): ClientSession { val session = client.connect(host.username, host.host, host.port) .verify(timeout).session if (host.authentication.type == AuthenticationType.Password) { @@ -73,6 +124,7 @@ object SshClients { return session } + /** * 打开一个客户端 */ @@ -81,7 +133,7 @@ object SshClients { builder.globalRequestHandlers(listOf(KeepAliveHandler.INSTANCE)) .factory { JGitSshClient() } - if (host.tunnelings.isEmpty()) { + if (host.tunnelings.isEmpty() && host.options.jumpHosts.isEmpty()) { builder.forwardingFilter(RejectAllForwardingFilter.INSTANCE) } else { builder.forwardingFilter(AcceptAllForwardingFilter.INSTANCE) diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index fba048e..3d2617d 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -154,6 +154,7 @@ termora.new-host.test-connection=Test Connection termora.new-host.test-connection-successful=Connection successful +termora.new-host.jump-hosts=Jump Hosts # Key manager termora.keymgr.title=Key Manager diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index 3aa0487..bcccef2 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -144,6 +144,8 @@ termora.new-host.tunneling.add=添加 termora.new-host.tunneling.edit=${termora.keymgr.edit} termora.new-host.tunneling.delete=${termora.remove} +termora.new-host.jump-hosts=跳板机 + # Key manager termora.keymgr.title=密钥管理器 termora.keymgr.generate=生成 diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index 8f0d134..7219957 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -141,6 +141,8 @@ termora.new-host.tunneling.add=添加 termora.new-host.tunneling.edit=${termora.keymgr.edit} termora.new-host.tunneling.delete=${termora.remove} +termora.new-host.jump-hosts=跳板機 + # Key manager termora.keymgr.title=密鑰管理器 termora.keymgr.generate=生成