feat: SFTP command (#234)

This commit is contained in:
hstyi
2025-02-15 11:23:06 +08:00
committed by GitHub
parent bdc0a15439
commit d8e629917e
14 changed files with 167 additions and 20 deletions

View File

@@ -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

View File

@@ -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())
}
})
}

View File

@@ -13,7 +13,13 @@ enum class Protocol {
Folder,
SSH,
Local,
Serial
Serial,
/**
* 交互式的 SFTP此协议只在系统内部交互不应该暴露给用户也不应该持久化
*/
@Transient
SFTPPty
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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) {

View 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()
}
}

View File

@@ -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()
// 关闭

View File

@@ -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)
}

View File

@@ -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-")

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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=关闭

View File

@@ -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=關閉