feat: support jump hosts

This commit is contained in:
hstyi
2025-01-15 15:11:26 +08:00
committed by hstyi
parent 45ea822fd6
commit 1476368673
7 changed files with 247 additions and 4 deletions

View File

@@ -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 {

View File

@@ -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<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
}
}
}

View File

@@ -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()

View File

@@ -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<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)
.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)

View File

@@ -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

View File

@@ -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=生成

View File

@@ -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=生成