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.awt.Desktop
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import kotlin.math.ln
|
import kotlin.math.ln
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
@@ -60,6 +62,16 @@ object Application {
|
|||||||
return "/bin/bash"
|
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 {
|
fun getBaseDataDir(): File {
|
||||||
if (::baseDataDir.isInitialized) {
|
if (::baseDataDir.isInitialized) {
|
||||||
return baseDataDir
|
return baseDataDir
|
||||||
|
|||||||
@@ -102,12 +102,12 @@ class ApplicationRunner {
|
|||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
|
||||||
// 启动时清除
|
// 启动时清除
|
||||||
FileUtils.cleanDirectory(File(Application.getBaseDataDir(), "temporary"))
|
FileUtils.cleanDirectory(Application.getTemporaryDir())
|
||||||
|
|
||||||
// 关闭时清除
|
// 关闭时清除
|
||||||
Disposer.register(ApplicationScope.forApplicationScope(), object : Disposable {
|
Disposer.register(ApplicationScope.forApplicationScope(), object : Disposable {
|
||||||
override fun dispose() {
|
override fun dispose() {
|
||||||
FileUtils.cleanDirectory(File(Application.getBaseDataDir(), "temporary"))
|
FileUtils.cleanDirectory(Application.getTemporaryDir())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ enum class Protocol {
|
|||||||
Folder,
|
Folder,
|
||||||
SSH,
|
SSH,
|
||||||
Local,
|
Local,
|
||||||
Serial
|
Serial,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交互式的 SFTP,此协议只在系统内部交互不应该暴露给用户也不应该持久化
|
||||||
|
*/
|
||||||
|
@Transient
|
||||||
|
SFTPPty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -101,8 +101,8 @@ class HostTree : JTree(), Disposable {
|
|||||||
|
|
||||||
icon = when (host.protocol) {
|
icon = when (host.protocol) {
|
||||||
Protocol.Folder -> if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon()
|
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
|
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
|
return c
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,20 @@ class PtyConnectorFactory : Disposable {
|
|||||||
rows: Int = 24, cols: Int = 80,
|
rows: Int = 24, cols: Int = 80,
|
||||||
env: Map<String, String> = emptyMap(),
|
env: Map<String, String> = emptyMap(),
|
||||||
charset: Charset = StandardCharsets.UTF_8
|
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 {
|
): PtyConnector {
|
||||||
val envs = mutableMapOf<String, String>()
|
val envs = mutableMapOf<String, String>()
|
||||||
envs.putAll(System.getenv())
|
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) {
|
if (log.isDebugEnabled) {
|
||||||
log.debug("command: {} , envs: {}", commands.joinToString(" "), envs)
|
log.debug("command: {} , envs: {}", commands.joinToString(" "), envs)
|
||||||
}
|
}
|
||||||
|
|
||||||
val ptyProcess = PtyProcessBuilder(commands.toTypedArray())
|
val ptyProcess = PtyProcessBuilder(commands)
|
||||||
.setEnvironment(envs)
|
.setEnvironment(envs)
|
||||||
.setInitialRows(rows)
|
.setInitialRows(rows)
|
||||||
.setInitialColumns(cols)
|
.setInitialColumns(cols)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ abstract class PtyHostTerminalTab(
|
|||||||
startPtyConnectorReader()
|
startPtyConnectorReader()
|
||||||
|
|
||||||
// 启动命令
|
// 启动命令
|
||||||
if (host.options.startupCommand.isNotBlank()) {
|
if (host.options.startupCommand.isNotBlank() && host.protocol != Protocol.SFTPPty) {
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
delay(250.milliseconds)
|
delay(250.milliseconds)
|
||||||
withContext(Dispatchers.Swing) {
|
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()
|
popupMenu.addSeparator()
|
||||||
|
|
||||||
// 关闭
|
// 关闭
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class OpenHostAction : AnAction() {
|
|||||||
val tab = when (evt.host.protocol) {
|
val tab = when (evt.host.protocol) {
|
||||||
Protocol.SSH -> SSHTerminalTab(windowScope, evt.host)
|
Protocol.SSH -> SSHTerminalTab(windowScope, evt.host)
|
||||||
Protocol.Serial -> SerialTerminalTab(windowScope, evt.host)
|
Protocol.Serial -> SerialTerminalTab(windowScope, evt.host)
|
||||||
|
Protocol.SFTPPty -> SFTPPtyTerminalTab(windowScope, evt.host)
|
||||||
else -> LocalTerminalTab(windowScope, evt.host)
|
else -> LocalTerminalTab(windowScope, evt.host)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -613,8 +613,7 @@ class FileSystemPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val temporary = Paths.get(Application.getBaseDataDir().absolutePath, "temporary")
|
val temporary = Application.getTemporaryDir().toPath()
|
||||||
Files.createDirectories(temporary)
|
|
||||||
|
|
||||||
for (file in files) {
|
for (file in files) {
|
||||||
val dir = Files.createTempDirectory(temporary, "termora-")
|
val dir = Files.createTempDirectory(temporary, "termora-")
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package app.termora.transport
|
package app.termora.transport
|
||||||
|
|
||||||
import app.termora.Host
|
import app.termora.*
|
||||||
import app.termora.Icons
|
|
||||||
import app.termora.SFTPTerminalTab
|
|
||||||
import app.termora.SSHTerminalTab
|
|
||||||
import app.termora.actions.AnAction
|
import app.termora.actions.AnAction
|
||||||
import app.termora.actions.AnActionEvent
|
import app.termora.actions.AnActionEvent
|
||||||
import app.termora.actions.DataProviders
|
import app.termora.actions.DataProviders
|
||||||
@@ -12,11 +9,12 @@ class SFTPAction : AnAction("SFTP", Icons.folder) {
|
|||||||
override fun actionPerformed(evt: AnActionEvent) {
|
override fun actionPerformed(evt: AnActionEvent) {
|
||||||
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
|
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
|
||||||
val selectedTerminalTab = terminalTabbedManager.getSelectedTerminalTab()
|
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
|
val tab = openOrCreateSFTPTerminalTab(evt) ?: return
|
||||||
|
|
||||||
if (host != null) {
|
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
|
# Tabbed
|
||||||
termora.tabbed.contextmenu.rename=Rename
|
termora.tabbed.contextmenu.rename=Rename
|
||||||
|
termora.tabbed.contextmenu.sftp-command=SFTP Command
|
||||||
termora.tabbed.contextmenu.clone=Clone
|
termora.tabbed.contextmenu.clone=Clone
|
||||||
termora.tabbed.contextmenu.open-in-new-window=Open in New Window
|
termora.tabbed.contextmenu.open-in-new-window=Open in New Window
|
||||||
termora.tabbed.contextmenu.close=Close
|
termora.tabbed.contextmenu.close=Close
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ termora.tools.multiple=将命令发送到所有会话
|
|||||||
|
|
||||||
# Tabbed
|
# Tabbed
|
||||||
termora.tabbed.contextmenu.rename=重命名
|
termora.tabbed.contextmenu.rename=重命名
|
||||||
|
termora.tabbed.contextmenu.sftp-command=SFTP 终端
|
||||||
termora.tabbed.contextmenu.clone=克隆
|
termora.tabbed.contextmenu.clone=克隆
|
||||||
termora.tabbed.contextmenu.open-in-new-window=在新窗口打开
|
termora.tabbed.contextmenu.open-in-new-window=在新窗口打开
|
||||||
termora.tabbed.contextmenu.close=关闭
|
termora.tabbed.contextmenu.close=关闭
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ termora.tools.multiple=將指令傳送到所有會話
|
|||||||
|
|
||||||
# Tabbed
|
# Tabbed
|
||||||
termora.tabbed.contextmenu.rename=重新命名
|
termora.tabbed.contextmenu.rename=重新命名
|
||||||
|
termora.tabbed.contextmenu.sftp-command=SFTP 終端
|
||||||
termora.tabbed.contextmenu.clone=克隆
|
termora.tabbed.contextmenu.clone=克隆
|
||||||
termora.tabbed.contextmenu.open-in-new-window=在新視窗打開
|
termora.tabbed.contextmenu.open-in-new-window=在新視窗打開
|
||||||
termora.tabbed.contextmenu.close=關閉
|
termora.tabbed.contextmenu.close=關閉
|
||||||
|
|||||||
Reference in New Issue
Block a user