mirror of
https://github.com/TermoraDev/termora.git
synced 2026-01-16 02:12:58 +08:00
feat: SFTP command (#234)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,7 +13,13 @@ enum class Protocol {
|
||||
Folder,
|
||||
SSH,
|
||||
Local,
|
||||
Serial
|
||||
Serial,
|
||||
|
||||
/**
|
||||
* 交互式的 SFTP,此协议只在系统内部交互不应该暴露给用户也不应该持久化
|
||||
*/
|
||||
@Transient
|
||||
SFTPPty
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -27,6 +27,20 @@ class PtyConnectorFactory : Disposable {
|
||||
rows: Int = 24, cols: Int = 80,
|
||||
env: Map<String, String> = 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<String>,
|
||||
rows: Int = 24, cols: Int = 80,
|
||||
env: Map<String, String> = emptyMap(),
|
||||
charset: Charset = StandardCharsets.UTF_8
|
||||
): PtyConnector {
|
||||
val envs = mutableMapOf<String, String>()
|
||||
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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
109
src/main/kotlin/app/termora/SFTPPtyTerminalTab.kt
Normal file
109
src/main/kotlin/app/termora/SFTPPtyTerminalTab.kt
Normal file
@@ -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<Path>()
|
||||
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<String>) {
|
||||
// 如果通过公钥连接
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
// 关闭
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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-")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=关闭
|
||||
|
||||
@@ -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=關閉
|
||||
|
||||
Reference in New Issue
Block a user