mirror of
https://github.com/TermoraDev/termora.git
synced 2026-01-16 02:12:58 +08:00
feat: support jump hosts
This commit is contained in:
@@ -34,6 +34,15 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
|
|||||||
terminalOption.heartbeatIntervalTextField.value = host.options.heartbeatInterval
|
terminalOption.heartbeatIntervalTextField.value = host.options.heartbeatInterval
|
||||||
|
|
||||||
tunnelingOption.tunnelings.addAll(host.tunnelings)
|
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 {
|
override fun getHost(): Host {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import java.awt.*
|
|||||||
import java.awt.event.*
|
import java.awt.event.*
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
import javax.swing.*
|
import javax.swing.*
|
||||||
|
import javax.swing.table.DefaultTableCellRenderer
|
||||||
import javax.swing.table.DefaultTableModel
|
import javax.swing.table.DefaultTableModel
|
||||||
|
|
||||||
|
|
||||||
@@ -20,12 +21,14 @@ open class HostOptionsPane : OptionsPane() {
|
|||||||
protected val generalOption = GeneralOption()
|
protected val generalOption = GeneralOption()
|
||||||
protected val proxyOption = ProxyOption()
|
protected val proxyOption = ProxyOption()
|
||||||
protected val terminalOption = TerminalOption()
|
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 {
|
init {
|
||||||
addOption(generalOption)
|
addOption(generalOption)
|
||||||
addOption(proxyOption)
|
addOption(proxyOption)
|
||||||
addOption(tunnelingOption)
|
addOption(tunnelingOption)
|
||||||
|
addOption(jumpHostsOption)
|
||||||
addOption(terminalOption)
|
addOption(terminalOption)
|
||||||
|
|
||||||
setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8))
|
setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8))
|
||||||
@@ -69,6 +72,7 @@ open class HostOptionsPane : OptionsPane() {
|
|||||||
env = terminalOption.environmentTextArea.text,
|
env = terminalOption.environmentTextArea.text,
|
||||||
startupCommand = terminalOption.startupCommandTextField.text,
|
startupCommand = terminalOption.startupCommandTextField.text,
|
||||||
heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int,
|
heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int,
|
||||||
|
jumpHosts = jumpHostsOption.jumpHosts.map { it.id }
|
||||||
)
|
)
|
||||||
|
|
||||||
return Host(
|
return Host(
|
||||||
@@ -635,6 +639,12 @@ open class HostOptionsPane : OptionsPane() {
|
|||||||
model.addColumn(I18n.getString("termora.new-host.tunneling.table.destination"))
|
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.autoResizeMode = JTable.AUTO_RESIZE_SUBSEQUENT_COLUMNS
|
||||||
table.border = BorderFactory.createEmptyBorder()
|
table.border = BorderFactory.createEmptyBorder()
|
||||||
table.fillsViewportHeight = true
|
table.fillsViewportHeight = true
|
||||||
@@ -843,4 +853,168 @@ open class HostOptionsPane : OptionsPane() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
protected inner class JumpHostsOption : JPanel(BorderLayout()), Option {
|
||||||
|
val jumpHosts = mutableListOf<Host>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,10 @@ import java.awt.event.WindowEvent
|
|||||||
import javax.swing.*
|
import javax.swing.*
|
||||||
import javax.swing.tree.TreeSelectionModel
|
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()
|
private val tree = HostTree()
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import app.termora.terminal.TerminalSize
|
|||||||
import org.apache.sshd.client.ClientBuilder
|
import org.apache.sshd.client.ClientBuilder
|
||||||
import org.apache.sshd.client.SshClient
|
import org.apache.sshd.client.SshClient
|
||||||
import org.apache.sshd.client.channel.ChannelShell
|
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.client.session.ClientSession
|
||||||
import org.apache.sshd.common.SshException
|
import org.apache.sshd.common.SshException
|
||||||
import org.apache.sshd.common.channel.PtyChannelConfiguration
|
import org.apache.sshd.common.channel.PtyChannelConfiguration
|
||||||
import org.apache.sshd.common.global.KeepAliveHandler
|
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.core.CoreModuleProperties
|
||||||
import org.apache.sshd.server.forward.AcceptAllForwardingFilter
|
import org.apache.sshd.server.forward.AcceptAllForwardingFilter
|
||||||
import org.apache.sshd.server.forward.RejectAllForwardingFilter
|
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.CredentialsProvider
|
||||||
import org.eclipse.jgit.transport.sshd.IdentityPasswordProvider
|
import org.eclipse.jgit.transport.sshd.IdentityPasswordProvider
|
||||||
import org.eclipse.jgit.transport.sshd.ProxyData
|
import org.eclipse.jgit.transport.sshd.ProxyData
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.Proxy
|
import java.net.Proxy
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import org.apache.sshd.client.config.hosts.HostConfigEntryResolver
|
|
||||||
|
|
||||||
object SshClients {
|
object SshClients {
|
||||||
private val timeout = Duration.ofSeconds(30)
|
private val timeout = Duration.ofSeconds(30)
|
||||||
|
private val log by lazy { LoggerFactory.getLogger(SshClients::class.java) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开一个 Shell
|
* 打开一个 Shell
|
||||||
@@ -57,6 +60,54 @@ object SshClients {
|
|||||||
* 打开一个会话
|
* 打开一个会话
|
||||||
*/
|
*/
|
||||||
fun openSession(host: Host, client: SshClient): ClientSession {
|
fun openSession(host: Host, client: SshClient): ClientSession {
|
||||||
|
|
||||||
|
|
||||||
|
// 如果没有跳板机直接连接
|
||||||
|
if (host.options.jumpHosts.isEmpty()) {
|
||||||
|
return doOpenSession(host, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
val jumpHosts = mutableListOf<Host>()
|
||||||
|
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<ClientSession>()
|
||||||
|
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)
|
val session = client.connect(host.username, host.host, host.port)
|
||||||
.verify(timeout).session
|
.verify(timeout).session
|
||||||
if (host.authentication.type == AuthenticationType.Password) {
|
if (host.authentication.type == AuthenticationType.Password) {
|
||||||
@@ -73,6 +124,7 @@ object SshClients {
|
|||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开一个客户端
|
* 打开一个客户端
|
||||||
*/
|
*/
|
||||||
@@ -81,7 +133,7 @@ object SshClients {
|
|||||||
builder.globalRequestHandlers(listOf(KeepAliveHandler.INSTANCE))
|
builder.globalRequestHandlers(listOf(KeepAliveHandler.INSTANCE))
|
||||||
.factory { JGitSshClient() }
|
.factory { JGitSshClient() }
|
||||||
|
|
||||||
if (host.tunnelings.isEmpty()) {
|
if (host.tunnelings.isEmpty() && host.options.jumpHosts.isEmpty()) {
|
||||||
builder.forwardingFilter(RejectAllForwardingFilter.INSTANCE)
|
builder.forwardingFilter(RejectAllForwardingFilter.INSTANCE)
|
||||||
} else {
|
} else {
|
||||||
builder.forwardingFilter(AcceptAllForwardingFilter.INSTANCE)
|
builder.forwardingFilter(AcceptAllForwardingFilter.INSTANCE)
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ termora.new-host.test-connection=Test Connection
|
|||||||
termora.new-host.test-connection-successful=Connection successful
|
termora.new-host.test-connection-successful=Connection successful
|
||||||
|
|
||||||
|
|
||||||
|
termora.new-host.jump-hosts=Jump Hosts
|
||||||
|
|
||||||
# Key manager
|
# Key manager
|
||||||
termora.keymgr.title=Key Manager
|
termora.keymgr.title=Key Manager
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ termora.new-host.tunneling.add=添加
|
|||||||
termora.new-host.tunneling.edit=${termora.keymgr.edit}
|
termora.new-host.tunneling.edit=${termora.keymgr.edit}
|
||||||
termora.new-host.tunneling.delete=${termora.remove}
|
termora.new-host.tunneling.delete=${termora.remove}
|
||||||
|
|
||||||
|
termora.new-host.jump-hosts=跳板机
|
||||||
|
|
||||||
# Key manager
|
# Key manager
|
||||||
termora.keymgr.title=密钥管理器
|
termora.keymgr.title=密钥管理器
|
||||||
termora.keymgr.generate=生成
|
termora.keymgr.generate=生成
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ termora.new-host.tunneling.add=添加
|
|||||||
termora.new-host.tunneling.edit=${termora.keymgr.edit}
|
termora.new-host.tunneling.edit=${termora.keymgr.edit}
|
||||||
termora.new-host.tunneling.delete=${termora.remove}
|
termora.new-host.tunneling.delete=${termora.remove}
|
||||||
|
|
||||||
|
termora.new-host.jump-hosts=跳板機
|
||||||
|
|
||||||
# Key manager
|
# Key manager
|
||||||
termora.keymgr.title=密鑰管理器
|
termora.keymgr.title=密鑰管理器
|
||||||
termora.keymgr.generate=生成
|
termora.keymgr.generate=生成
|
||||||
|
|||||||
Reference in New Issue
Block a user