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
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=生成
|
||||
|
||||
@@ -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=生成
|
||||
|
||||
Reference in New Issue
Block a user