diff --git a/src/main/kotlin/app/termora/PtyHostTerminalTab.kt b/src/main/kotlin/app/termora/PtyHostTerminalTab.kt index abe323a..0db2d26 100644 --- a/src/main/kotlin/app/termora/PtyHostTerminalTab.kt +++ b/src/main/kotlin/app/termora/PtyHostTerminalTab.kt @@ -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() diff --git a/src/main/kotlin/app/termora/TerminalFactory.kt b/src/main/kotlin/app/termora/TerminalFactory.kt index fc955db..0892c50 100644 --- a/src/main/kotlin/app/termora/TerminalFactory.kt +++ b/src/main/kotlin/app/termora/TerminalFactory.kt @@ -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 diff --git a/src/main/kotlin/app/termora/TerminalPanelFactory.kt b/src/main/kotlin/app/termora/TerminalPanelFactory.kt index 025f83c..c9dd6e5 100644 --- a/src/main/kotlin/app/termora/TerminalPanelFactory.kt +++ b/src/main/kotlin/app/termora/TerminalPanelFactory.kt @@ -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) + } + } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/keymgr/KeyManagerPanel.kt b/src/main/kotlin/app/termora/keymgr/KeyManagerPanel.kt index 56e8446..9a1170c 100644 --- a/src/main/kotlin/app/termora/keymgr/KeyManagerPanel.kt +++ b/src/main/kotlin/app/termora/keymgr/KeyManagerPanel.kt @@ -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() + 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) { file.outputStream().use { fis -> val names = mutableMapOf() diff --git a/src/main/kotlin/app/termora/keymgr/SSHCopyIdDialog.kt b/src/main/kotlin/app/termora/keymgr/SSHCopyIdDialog.kt new file mode 100644 index 0000000..813f2eb --- /dev/null +++ b/src/main/kotlin/app/termora/keymgr/SSHCopyIdDialog.kt @@ -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, + private val publicKeys: List, +) : 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 { + 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")) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt b/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt index 7fab2a5..3821615 100644 --- a/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt +++ b/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt @@ -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) diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index d026cd1..f8715ef 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -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 diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index 5b38168..5b5c659 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -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=将命令发送到所有会话 diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index 806076c..2536217 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -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=將指令傳送到所有會話