mirror of
https://github.com/TermoraDev/termora.git
synced 2026-01-16 02:12:58 +08:00
feat: support ssh-copy-id (#177)
This commit is contained in:
@@ -23,8 +23,9 @@ abstract class PtyHostTerminalTab(
|
||||
private var readerJob: Job? = null
|
||||
private val ptyConnectorDelegate = PtyConnectorDelegate()
|
||||
|
||||
protected val terminalPanel =
|
||||
TerminalPanelFactory.getInstance(windowScope).createTerminalPanel(terminal, ptyConnectorDelegate)
|
||||
private val terminalPanelFactory = TerminalPanelFactory.getInstance(windowScope)
|
||||
protected val terminalPanel = terminalPanelFactory.createTerminalPanel(terminal, ptyConnectorDelegate)
|
||||
.apply { Disposer.register(this@PtyHostTerminalTab, this) }
|
||||
protected val ptyConnectorFactory get() = PtyConnectorFactory.getInstance(windowScope)
|
||||
|
||||
init {
|
||||
@@ -120,6 +121,7 @@ abstract class PtyHostTerminalTab(
|
||||
|
||||
override fun dispose() {
|
||||
stop()
|
||||
terminalPanel
|
||||
super.dispose()
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ class TerminalFactory private constructor() : Disposable {
|
||||
|
||||
// terminal logger listener
|
||||
terminal.getTerminalModel().addDataListener(TerminalLoggerDataListener(terminal))
|
||||
terminal.addTerminalListener(object : TerminalListener {
|
||||
override fun onClose(terminal: Terminal) {
|
||||
terminals.remove(terminal)
|
||||
}
|
||||
})
|
||||
|
||||
terminals.add(terminal)
|
||||
return terminal
|
||||
|
||||
@@ -23,6 +23,11 @@ class TerminalPanelFactory {
|
||||
terminalPanel.addTerminalPaintListener(MultipleTerminalListener())
|
||||
terminalPanel.addTerminalPaintListener(KeywordHighlightPaintListener.getInstance())
|
||||
terminalPanel.addTerminalPaintListener(TerminalHyperlinkPaintListener.getInstance())
|
||||
Disposer.register(terminalPanel, object : Disposable {
|
||||
override fun dispose() {
|
||||
terminalPanels.remove(terminalPanel)
|
||||
}
|
||||
})
|
||||
terminalPanels.add(terminalPanel)
|
||||
return terminalPanel
|
||||
}
|
||||
@@ -47,4 +52,8 @@ class TerminalPanelFactory {
|
||||
}
|
||||
}
|
||||
|
||||
fun removeTerminalPanel(terminalPanel: TerminalPanel) {
|
||||
terminalPanels.remove(terminalPanel)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package app.termora.keymgr
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.AES.decodeBase64
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.native.FileChooser
|
||||
import com.formdev.flatlaf.extras.components.FlatComboBox
|
||||
import com.formdev.flatlaf.extras.components.FlatTable
|
||||
@@ -48,6 +51,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
|
||||
private val exportBtn = JButton(I18n.getString("termora.keymgr.export"))
|
||||
private val editBtn = JButton(I18n.getString("termora.keymgr.edit"))
|
||||
private val deleteBtn = JButton(I18n.getString("termora.remove"))
|
||||
private val sshCopyIdBtn = JButton("ssh-copy-id")
|
||||
|
||||
init {
|
||||
initView()
|
||||
@@ -59,6 +63,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
|
||||
|
||||
exportBtn.isEnabled = false
|
||||
editBtn.isEnabled = false
|
||||
sshCopyIdBtn.isEnabled = false
|
||||
deleteBtn.isEnabled = false
|
||||
|
||||
keyPairTableModel.addColumn(I18n.getString("termora.keymgr.table.name"))
|
||||
@@ -75,7 +80,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
|
||||
val formMargin = "4dlu"
|
||||
val layout = FormLayout(
|
||||
"default:grow",
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin"
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, 16dlu, pref"
|
||||
)
|
||||
|
||||
var rows = 1
|
||||
@@ -91,6 +96,7 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
|
||||
.add(importBtn).xy(1, rows).apply { rows += step }
|
||||
.add(exportBtn).xy(1, rows).apply { rows += step }
|
||||
.add(deleteBtn).xy(1, rows).apply { rows += step }
|
||||
.add(sshCopyIdBtn).xy(1, rows).apply { rows += step }
|
||||
.build(), BorderLayout.EAST)
|
||||
border = BorderFactory.createEmptyBorder(if (SystemInfo.isWindows || SystemInfo.isLinux) 6 else 0, 12, 12, 12)
|
||||
|
||||
@@ -175,13 +181,48 @@ class KeyManagerPanel : JPanel(BorderLayout()) {
|
||||
}
|
||||
})
|
||||
|
||||
sshCopyIdBtn.addActionListener(object : AnAction() {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
sshCopyId(evt)
|
||||
}
|
||||
})
|
||||
|
||||
keyPairTable.selectionModel.addListSelectionListener {
|
||||
exportBtn.isEnabled = keyPairTable.selectedRowCount > 0
|
||||
editBtn.isEnabled = exportBtn.isEnabled
|
||||
deleteBtn.isEnabled = exportBtn.isEnabled
|
||||
sshCopyIdBtn.isEnabled = exportBtn.isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
private fun sshCopyId(evt: AnActionEvent) {
|
||||
val windowScope = evt.getData(DataProviders.WindowScope) ?: return
|
||||
val keyPairs = keyPairTable.selectedRows.map { keyPairTableModel.getOhKeyPair(it) }
|
||||
val publicKeys = mutableListOf<String>()
|
||||
for (keyPair in keyPairs) {
|
||||
val publicKey = OhKeyPairKeyPairProvider.generateKeyPair(keyPair).public
|
||||
val baos = ByteArrayOutputStream()
|
||||
OpenSSHKeyPairResourceWriter.INSTANCE.writePublicKey(publicKey, keyPair.name, baos)
|
||||
publicKeys.add(baos.toString(Charsets.UTF_8))
|
||||
}
|
||||
|
||||
if (publicKeys.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val owner = SwingUtilities.getWindowAncestor(this) ?: return
|
||||
val hostTreeDialog = HostTreeDialog(owner) {
|
||||
it.protocol == Protocol.SSH
|
||||
}
|
||||
hostTreeDialog.isVisible = true
|
||||
val hosts = hostTreeDialog.hosts
|
||||
if (hosts.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
SSHCopyIdDialog(owner, windowScope, hosts, publicKeys).start()
|
||||
}
|
||||
|
||||
private fun exportKeyPairs(file: File, keyPairs: List<OhKeyPair>) {
|
||||
file.outputStream().use { fis ->
|
||||
val names = mutableMapOf<String, Int>()
|
||||
|
||||
186
src/main/kotlin/app/termora/keymgr/SSHCopyIdDialog.kt
Normal file
186
src/main/kotlin/app/termora/keymgr/SSHCopyIdDialog.kt
Normal file
@@ -0,0 +1,186 @@
|
||||
package app.termora.keymgr
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.keyboardinteractive.TerminalUserInteraction
|
||||
import app.termora.terminal.ControlCharacters
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.terminal.PtyConnectorDelegate
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.sshd.client.SshClient
|
||||
import org.apache.sshd.client.channel.ClientChannelEvent
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.time.Duration
|
||||
import java.util.*
|
||||
import javax.swing.AbstractAction
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.UIManager
|
||||
|
||||
class SSHCopyIdDialog(
|
||||
owner: Window,
|
||||
private val windowScope: WindowScope,
|
||||
private val hosts: List<Host>,
|
||||
private val publicKeys: List<String>,
|
||||
) : DialogWrapper(owner) {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(SSHCopyIdDialog::class.java)
|
||||
}
|
||||
|
||||
private val terminalPanelFactory = TerminalPanelFactory.getInstance(windowScope)
|
||||
private val terminal by lazy {
|
||||
TerminalFactory.getInstance(windowScope).createTerminal().apply {
|
||||
getTerminalModel().setData(DataKey.ShowCursor, false)
|
||||
getTerminalModel().setData(DataKey.AutoNewline, true)
|
||||
}
|
||||
}
|
||||
private val terminalPanel by lazy {
|
||||
terminalPanelFactory.createTerminalPanel(terminal, PtyConnectorDelegate())
|
||||
}
|
||||
private val coroutineScope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
|
||||
init {
|
||||
size = Dimension(UIManager.getInt("Dialog.width") - 100, UIManager.getInt("Dialog.height") - 100)
|
||||
isModal = true
|
||||
title = "SSH Copy ID"
|
||||
setLocationRelativeTo(null)
|
||||
|
||||
Disposer.register(disposable, object : Disposable {
|
||||
override fun dispose() {
|
||||
coroutineScope.cancel()
|
||||
terminal.close()
|
||||
Disposer.dispose(terminalPanel)
|
||||
}
|
||||
})
|
||||
|
||||
init()
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
return terminalPanel
|
||||
}
|
||||
|
||||
fun start() {
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
doStart()
|
||||
} catch (e: Exception) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
isVisible = true
|
||||
}
|
||||
|
||||
|
||||
override fun createActions(): List<AbstractAction> {
|
||||
return listOf(CancelAction())
|
||||
}
|
||||
|
||||
private fun magenta(text: Any): String {
|
||||
return "${ControlCharacters.ESC}[35m${text}${ControlCharacters.ESC}[0m"
|
||||
}
|
||||
|
||||
private fun cyan(text: Any): String {
|
||||
return "${ControlCharacters.ESC}[36m${text}${ControlCharacters.ESC}[0m"
|
||||
}
|
||||
|
||||
private fun red(text: Any): String {
|
||||
return "${ControlCharacters.ESC}[31m${text}${ControlCharacters.ESC}[0m"
|
||||
}
|
||||
|
||||
private fun green(text: Any): String {
|
||||
return "${ControlCharacters.ESC}[32m${text}${ControlCharacters.ESC}[0m"
|
||||
}
|
||||
|
||||
private suspend fun doStart() {
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.write(
|
||||
I18n.getString(
|
||||
"termora.keymgr.ssh-copy-id.number",
|
||||
magenta(hosts.size),
|
||||
magenta(publicKeys.size)
|
||||
)
|
||||
)
|
||||
terminal.getDocument().newline()
|
||||
terminal.getDocument().newline()
|
||||
}
|
||||
|
||||
var myClient: SshClient? = null
|
||||
var mySession: ClientSession? = null
|
||||
val timeout = Duration.ofMinutes(1)
|
||||
|
||||
for (index in hosts.indices) {
|
||||
if (!coroutineScope.isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
val host = hosts[index]
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.write("[${cyan(index + 1)}/${cyan(hosts.size)}] ${host.name}")
|
||||
terminal.getDocument().newline()
|
||||
}
|
||||
|
||||
for (j in publicKeys.indices) {
|
||||
if (!coroutineScope.isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
val publicKey = publicKeys[j]
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.write("\t[${cyan(j + 1)}/${cyan(publicKeys.size)}] ${I18n.getString("termora.transport.sftp.connecting")}")
|
||||
}
|
||||
|
||||
try {
|
||||
val client = SshClients.openClient(host).apply { myClient = this }
|
||||
client.userInteraction = TerminalUserInteraction(owner)
|
||||
val session = SshClients.openSession(host, client).apply { mySession = this }
|
||||
val channel =
|
||||
session.createExecChannel("mkdir -p ~/.ssh && grep -qxF \"$publicKey\" ~/.ssh/authorized_keys || echo \"$publicKey\" >> ~/.ssh/authorized_keys")
|
||||
val baos = ByteArrayOutputStream()
|
||||
channel.out = baos
|
||||
if (channel.open().verify(timeout).await(timeout)) {
|
||||
channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), timeout);
|
||||
}
|
||||
if (channel.exitStatus != 0) {
|
||||
throw IllegalStateException("Server response: ${channel.exitStatus}")
|
||||
}
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.getDocument().eraseInLine(2)
|
||||
terminal.write("\r\t[${cyan(j + 1)}/${cyan(publicKeys.size)}] ${green(I18n.getString("termora.keymgr.ssh-copy-id.successful"))}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.getDocument().eraseInLine(2)
|
||||
terminal.write("\r\t[${cyan(j + 1)}/${cyan(publicKeys.size)}] ${red("${I18n.getString("termora.keymgr.ssh-copy-id.failed")}: ${e.message}")}")
|
||||
}
|
||||
} finally {
|
||||
IOUtils.closeQuietly(mySession)
|
||||
IOUtils.closeQuietly(myClient)
|
||||
}
|
||||
|
||||
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.getDocument().newline()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.getDocument().newline()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
terminal.write(I18n.getString("termora.keymgr.ssh-copy-id.end"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.termora.terminal.panel
|
||||
|
||||
import app.termora.Disposable
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.actions.DataProviderSupport
|
||||
import app.termora.actions.DataProviders
|
||||
@@ -30,7 +31,7 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
|
||||
class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnector) :
|
||||
JPanel(BorderLayout()), DataProvider {
|
||||
JPanel(BorderLayout()), DataProvider, Disposable {
|
||||
|
||||
companion object {
|
||||
val Debug = DataKey(Boolean::class)
|
||||
|
||||
@@ -186,6 +186,12 @@ termora.keymgr.table.type=Type
|
||||
termora.keymgr.table.length=Length
|
||||
termora.keymgr.table.remark=Description
|
||||
|
||||
termora.keymgr.ssh-copy-id.number=Number of hosts [{0}] Number of public keys [{1}]
|
||||
termora.keymgr.ssh-copy-id.successful=Copy Success
|
||||
termora.keymgr.ssh-copy-id.failed=Copy Failure
|
||||
termora.keymgr.ssh-copy-id.end=End of public key copying
|
||||
|
||||
|
||||
# Tabbed
|
||||
termora.tabbed.contextmenu.rename=Rename
|
||||
termora.tabbed.contextmenu.clone=Clone
|
||||
|
||||
@@ -172,6 +172,11 @@ termora.keymgr.table.type=类型
|
||||
termora.keymgr.table.length=长度
|
||||
termora.keymgr.table.remark=备注
|
||||
|
||||
termora.keymgr.ssh-copy-id.number=主机数量 [{0}] 公钥数量 [{1}]
|
||||
termora.keymgr.ssh-copy-id.successful=复制成功
|
||||
termora.keymgr.ssh-copy-id.failed=复制失败
|
||||
termora.keymgr.ssh-copy-id.end=复制公钥结束
|
||||
|
||||
# Tools
|
||||
termora.tools.multiple=将命令发送到所有会话
|
||||
|
||||
|
||||
@@ -169,6 +169,11 @@ termora.keymgr.table.type=型別
|
||||
termora.keymgr.table.length=長度
|
||||
termora.keymgr.table.remark=備註
|
||||
|
||||
termora.keymgr.ssh-copy-id.number=主機數量 [{0}] 公鑰數量 [{1}]
|
||||
termora.keymgr.ssh-copy-id.successful=複製成功
|
||||
termora.keymgr.ssh-copy-id.failed=複製失敗
|
||||
termora.keymgr.ssh-copy-id.end=複製公鑰結束
|
||||
|
||||
# Tools
|
||||
termora.tools.multiple=將指令傳送到所有會話
|
||||
|
||||
|
||||
Reference in New Issue
Block a user