From d8e629917e8efe8e81d6a8188cc4e3e72af43afb Mon Sep 17 00:00:00 2001 From: hstyi Date: Sat, 15 Feb 2025 11:23:06 +0800 Subject: [PATCH] feat: SFTP command (#234) --- src/main/kotlin/app/termora/Application.kt | 12 ++ .../kotlin/app/termora/ApplicationRunner.kt | 4 +- src/main/kotlin/app/termora/Host.kt | 8 +- src/main/kotlin/app/termora/HostTree.kt | 2 +- .../kotlin/app/termora/PtyConnectorFactory.kt | 22 ++-- .../kotlin/app/termora/PtyHostTerminalTab.kt | 2 +- .../kotlin/app/termora/SFTPPtyTerminalTab.kt | 109 ++++++++++++++++++ src/main/kotlin/app/termora/TerminalTabbed.kt | 11 ++ .../app/termora/actions/OpenHostAction.kt | 1 + .../app/termora/transport/FileSystemPanel.kt | 3 +- .../app/termora/transport/SFTPAction.kt | 10 +- src/main/resources/i18n/messages.properties | 1 + .../resources/i18n/messages_zh_CN.properties | 1 + .../resources/i18n/messages_zh_TW.properties | 1 + 14 files changed, 167 insertions(+), 20 deletions(-) create mode 100644 src/main/kotlin/app/termora/SFTPPtyTerminalTab.kt diff --git a/src/main/kotlin/app/termora/Application.kt b/src/main/kotlin/app/termora/Application.kt index 2be996b..116b650 100644 --- a/src/main/kotlin/app/termora/Application.kt +++ b/src/main/kotlin/app/termora/Application.kt @@ -15,6 +15,8 @@ import org.slf4j.LoggerFactory import java.awt.Desktop import java.io.File import java.net.URI +import java.nio.file.Files +import java.nio.file.Path import java.time.Duration import kotlin.math.ln import kotlin.math.pow @@ -60,6 +62,16 @@ object Application { return "/bin/bash" } + fun getTemporaryDir(): File { + val temporaryDir = File(getBaseDataDir(), "temporary") + FileUtils.forceMkdir(temporaryDir) + return temporaryDir + } + + fun createSubTemporaryDir(prefix: String = getName()): Path { + return Files.createTempDirectory(getTemporaryDir().toPath(), prefix) + } + fun getBaseDataDir(): File { if (::baseDataDir.isInitialized) { return baseDataDir diff --git a/src/main/kotlin/app/termora/ApplicationRunner.kt b/src/main/kotlin/app/termora/ApplicationRunner.kt index 0b89078..f70202d 100644 --- a/src/main/kotlin/app/termora/ApplicationRunner.kt +++ b/src/main/kotlin/app/termora/ApplicationRunner.kt @@ -102,12 +102,12 @@ class ApplicationRunner { GlobalScope.launch(Dispatchers.IO) { // 启动时清除 - FileUtils.cleanDirectory(File(Application.getBaseDataDir(), "temporary")) + FileUtils.cleanDirectory(Application.getTemporaryDir()) // 关闭时清除 Disposer.register(ApplicationScope.forApplicationScope(), object : Disposable { override fun dispose() { - FileUtils.cleanDirectory(File(Application.getBaseDataDir(), "temporary")) + FileUtils.cleanDirectory(Application.getTemporaryDir()) } }) } diff --git a/src/main/kotlin/app/termora/Host.kt b/src/main/kotlin/app/termora/Host.kt index 6edf479..2dbc946 100644 --- a/src/main/kotlin/app/termora/Host.kt +++ b/src/main/kotlin/app/termora/Host.kt @@ -13,7 +13,13 @@ enum class Protocol { Folder, SSH, Local, - Serial + Serial, + + /** + * 交互式的 SFTP,此协议只在系统内部交互不应该暴露给用户也不应该持久化 + */ + @Transient + SFTPPty } diff --git a/src/main/kotlin/app/termora/HostTree.kt b/src/main/kotlin/app/termora/HostTree.kt index 18686a6..d1991f9 100644 --- a/src/main/kotlin/app/termora/HostTree.kt +++ b/src/main/kotlin/app/termora/HostTree.kt @@ -101,8 +101,8 @@ class HostTree : JTree(), Disposable { icon = when (host.protocol) { Protocol.Folder -> if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon() - Protocol.SSH, Protocol.Local -> if (sel && this@HostTree.hasFocus()) Icons.terminal.dark else Icons.terminal Protocol.Serial -> if (sel && this@HostTree.hasFocus()) Icons.plugin.dark else Icons.plugin + else -> if (sel && this@HostTree.hasFocus()) Icons.terminal.dark else Icons.terminal } return c } diff --git a/src/main/kotlin/app/termora/PtyConnectorFactory.kt b/src/main/kotlin/app/termora/PtyConnectorFactory.kt index 6433d82..24caab9 100644 --- a/src/main/kotlin/app/termora/PtyConnectorFactory.kt +++ b/src/main/kotlin/app/termora/PtyConnectorFactory.kt @@ -27,6 +27,20 @@ class PtyConnectorFactory : Disposable { rows: Int = 24, cols: Int = 80, env: Map = emptyMap(), charset: Charset = StandardCharsets.UTF_8 + ): PtyConnector { + val command = database.terminal.localShell + val commands = mutableListOf(command) + if (SystemUtils.IS_OS_UNIX) { + commands.add("-l") + } + return createPtyConnector(commands.toTypedArray(), rows, cols, env, charset) + } + + fun createPtyConnector( + commands: Array, + rows: Int = 24, cols: Int = 80, + env: Map = emptyMap(), + charset: Charset = StandardCharsets.UTF_8 ): PtyConnector { val envs = mutableMapOf() envs.putAll(System.getenv()) @@ -44,17 +58,11 @@ class PtyConnectorFactory : Disposable { } } - val command = database.terminal.localShell - val commands = mutableListOf(command) - if (SystemUtils.IS_OS_UNIX) { - commands.add("-l") - } - if (log.isDebugEnabled) { log.debug("command: {} , envs: {}", commands.joinToString(" "), envs) } - val ptyProcess = PtyProcessBuilder(commands.toTypedArray()) + val ptyProcess = PtyProcessBuilder(commands) .setEnvironment(envs) .setInitialRows(rows) .setInitialColumns(cols) diff --git a/src/main/kotlin/app/termora/PtyHostTerminalTab.kt b/src/main/kotlin/app/termora/PtyHostTerminalTab.kt index 0db2d26..7d138d7 100644 --- a/src/main/kotlin/app/termora/PtyHostTerminalTab.kt +++ b/src/main/kotlin/app/termora/PtyHostTerminalTab.kt @@ -50,7 +50,7 @@ abstract class PtyHostTerminalTab( startPtyConnectorReader() // 启动命令 - if (host.options.startupCommand.isNotBlank()) { + if (host.options.startupCommand.isNotBlank() && host.protocol != Protocol.SFTPPty) { coroutineScope.launch(Dispatchers.IO) { delay(250.milliseconds) withContext(Dispatchers.Swing) { diff --git a/src/main/kotlin/app/termora/SFTPPtyTerminalTab.kt b/src/main/kotlin/app/termora/SFTPPtyTerminalTab.kt new file mode 100644 index 0000000..c3f9965 --- /dev/null +++ b/src/main/kotlin/app/termora/SFTPPtyTerminalTab.kt @@ -0,0 +1,109 @@ +package app.termora + +import app.termora.keymgr.KeyManager +import app.termora.keymgr.OhKeyPairKeyPairProvider +import app.termora.terminal.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.swing.Swing +import kotlinx.coroutines.withContext +import org.apache.commons.io.Charsets +import org.apache.commons.io.FileUtils +import org.apache.commons.lang3.StringUtils +import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyPairResourceWriter +import java.awt.event.KeyEvent +import java.io.File +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path + +class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(windowScope, host) { + private val keyManager by lazy { KeyManager.getInstance() } + private val tempFiles = mutableListOf() + private val passwordDataListener = object : DataListener { + override fun onChanged(key: DataKey<*>, data: Any) { + if (key == VisualTerminal.Written && data is String) { + + // 要求输入密码 + val line = terminal.getDocument().getScreenLine(terminal.getCursorModel().getPosition().y) + if (line.getText().startsWith("${host.username}@${host.host}'s password:")) { + + // 删除密码监听 + terminal.getTerminalModel().removeDataListener(this) + + val ptyConnector = getPtyConnector() + + // password + ptyConnector.write(host.authentication.password.toByteArray(ptyConnector.getCharset())) + + // enter + ptyConnector.write( + terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER)) + .toByteArray(ptyConnector.getCharset()) + ) + } + + } + } + } + + override suspend fun openPtyConnector(): PtyConnector { + // 删除密码监听 + withContext(Dispatchers.Swing) { terminal.getTerminalModel().removeDataListener(passwordDataListener) } + + val winSize = terminalPanel.winSize() + val commands = mutableListOf("sftp") + + // known_hosts + commands.add("-o") + commands.add("UserKnownHostsFile=${File(Application.getBaseDataDir(), "known_hosts").absolutePath}") + + // Compression + commands.add("-o") + commands.add("Compression=yes") + + // port + commands.add("-P") + commands.add(host.port.toString()) + + // 设置认证信息 + setAuthentication(commands) + + commands.add("${host.username}@${host.host}") + + val ptyConnector = ptyConnectorFactory.createPtyConnector( + commands.toTypedArray(), + winSize.rows, winSize.cols, + host.options.envs(), + Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8), + ) + + return ptyConnector + } + + private fun setAuthentication(commands: MutableList) { + // 如果通过公钥连接 + if (host.authentication.type == AuthenticationType.PublicKey) { + val keyPair = keyManager.getOhKeyPair(host.authentication.password) + if (keyPair != null) { + val keyPair = OhKeyPairKeyPairProvider.generateKeyPair(keyPair) + val privateKeyPath = Application.createSubTemporaryDir() + val privateKeyFile = Files.createTempFile(privateKeyPath, Application.getName(), StringUtils.EMPTY) + Files.newOutputStream(privateKeyFile) + .use { OpenSSHKeyPairResourceWriter.INSTANCE.writePrivateKey(keyPair, null, null, it) } + commands.add("-i") + commands.add(privateKeyFile.toFile().absolutePath) + tempFiles.add(privateKeyPath) + } + } else if (host.authentication.type == AuthenticationType.Password) { + terminal.getTerminalModel().addDataListener(passwordDataListener) + } + } + + override fun stop() { + for (path in tempFiles) { + FileUtils.deleteQuietly(path.toFile()) + } + tempFiles.clear() + super.stop() + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/TerminalTabbed.kt b/src/main/kotlin/app/termora/TerminalTabbed.kt index 63f057e..b96ba4d 100644 --- a/src/main/kotlin/app/termora/TerminalTabbed.kt +++ b/src/main/kotlin/app/termora/TerminalTabbed.kt @@ -238,6 +238,17 @@ class TerminalTabbed( } }) + if (tab is HostTerminalTab) { + if (tab.host.protocol == Protocol.SSH || tab.host.protocol == Protocol.SFTPPty) { + popupMenu.addSeparator() + val sftpCommand = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.sftp-command")) + sftpCommand.addActionListener { + actionManager.getAction(OpenHostAction.OPEN_HOST) + ?.actionPerformed(OpenHostActionEvent(this, tab.host.copy(protocol = Protocol.SFTPPty), it)) + } + } + } + popupMenu.addSeparator() // 关闭 diff --git a/src/main/kotlin/app/termora/actions/OpenHostAction.kt b/src/main/kotlin/app/termora/actions/OpenHostAction.kt index 6ce97f1..40fddaf 100644 --- a/src/main/kotlin/app/termora/actions/OpenHostAction.kt +++ b/src/main/kotlin/app/termora/actions/OpenHostAction.kt @@ -18,6 +18,7 @@ class OpenHostAction : AnAction() { val tab = when (evt.host.protocol) { Protocol.SSH -> SSHTerminalTab(windowScope, evt.host) Protocol.Serial -> SerialTerminalTab(windowScope, evt.host) + Protocol.SFTPPty -> SFTPPtyTerminalTab(windowScope, evt.host) else -> LocalTerminalTab(windowScope, evt.host) } diff --git a/src/main/kotlin/app/termora/transport/FileSystemPanel.kt b/src/main/kotlin/app/termora/transport/FileSystemPanel.kt index c82e69f..fc40c4e 100644 --- a/src/main/kotlin/app/termora/transport/FileSystemPanel.kt +++ b/src/main/kotlin/app/termora/transport/FileSystemPanel.kt @@ -613,8 +613,7 @@ class FileSystemPanel( } } - val temporary = Paths.get(Application.getBaseDataDir().absolutePath, "temporary") - Files.createDirectories(temporary) + val temporary = Application.getTemporaryDir().toPath() for (file in files) { val dir = Files.createTempDirectory(temporary, "termora-") diff --git a/src/main/kotlin/app/termora/transport/SFTPAction.kt b/src/main/kotlin/app/termora/transport/SFTPAction.kt index dfee745..f4bf637 100644 --- a/src/main/kotlin/app/termora/transport/SFTPAction.kt +++ b/src/main/kotlin/app/termora/transport/SFTPAction.kt @@ -1,9 +1,6 @@ package app.termora.transport -import app.termora.Host -import app.termora.Icons -import app.termora.SFTPTerminalTab -import app.termora.SSHTerminalTab +import app.termora.* import app.termora.actions.AnAction import app.termora.actions.AnActionEvent import app.termora.actions.DataProviders @@ -12,11 +9,12 @@ class SFTPAction : AnAction("SFTP", Icons.folder) { override fun actionPerformed(evt: AnActionEvent) { val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return val selectedTerminalTab = terminalTabbedManager.getSelectedTerminalTab() - val host = if (selectedTerminalTab is SSHTerminalTab) selectedTerminalTab.host else null + val host = if (selectedTerminalTab is SSHTerminalTab || selectedTerminalTab is SFTPPtyTerminalTab) + selectedTerminalTab.host else null val tab = openOrCreateSFTPTerminalTab(evt) ?: return if (host != null) { - connectHost(host, tab) + connectHost(host.copy(protocol = Protocol.SSH), tab) } } diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 35616e7..5c750fa 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -206,6 +206,7 @@ termora.keymgr.ssh-copy-id.end=End of public key copying # Tabbed termora.tabbed.contextmenu.rename=Rename +termora.tabbed.contextmenu.sftp-command=SFTP Command termora.tabbed.contextmenu.clone=Clone termora.tabbed.contextmenu.open-in-new-window=Open in New Window termora.tabbed.contextmenu.close=Close diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index 6eab2bc..e308c92 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -198,6 +198,7 @@ termora.tools.multiple=将命令发送到所有会话 # Tabbed termora.tabbed.contextmenu.rename=重命名 +termora.tabbed.contextmenu.sftp-command=SFTP 终端 termora.tabbed.contextmenu.clone=克隆 termora.tabbed.contextmenu.open-in-new-window=在新窗口打开 termora.tabbed.contextmenu.close=关闭 diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index 69b90e8..6bf0514 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -194,6 +194,7 @@ termora.tools.multiple=將指令傳送到所有會話 # Tabbed termora.tabbed.contextmenu.rename=重新命名 +termora.tabbed.contextmenu.sftp-command=SFTP 終端 termora.tabbed.contextmenu.clone=克隆 termora.tabbed.contextmenu.open-in-new-window=在新視窗打開 termora.tabbed.contextmenu.close=關閉