feat: SSH support ssh-agent (#433)

This commit is contained in:
hstyi
2025-03-30 12:48:14 +08:00
committed by GitHub
parent c714f33a44
commit 283404b6b9
12 changed files with 183 additions and 103 deletions

View File

@@ -166,6 +166,10 @@ org.eclipse.jgit.ssh.apache
Eclipse Distribution License
https://www.eclipse.org/org/documents/edl-v10.php
org.eclipse.jgit.ssh.apache.agent
Eclipse Distribution License
https://www.eclipse.org/org/documents/edl-v10.php
org.eclipse.jgit
Eclipse Distribution License
https://www.eclipse.org/org/documents/edl-v10.php

View File

@@ -104,6 +104,7 @@ dependencies {
implementation(libs.commonmark)
implementation(libs.jgit)
implementation(libs.jgit.sshd) { exclude(group = "*", module = "sshd-osgi") }
implementation(libs.jgit.agent) { exclude(group = "*", module = "sshd-osgi") }
implementation(libs.jnafilechooser)
implementation(libs.xodus.vfs)
implementation(libs.xodus.openAPI)

View File

@@ -80,6 +80,7 @@ sshd-core = { module = "org.apache.sshd:sshd-core", version.ref = "sshd-core" }
jgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version.ref = "jgit" }
commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" }
jgit-sshd = { module = "org.eclipse.jgit:org.eclipse.jgit.ssh.apache", version.ref = "jgit" }
jgit-agent = { module = "org.eclipse.jgit:org.eclipse.jgit.ssh.apache.agent", version.ref = "jgit" }
xodus-openAPI = { module = "org.jetbrains.xodus:xodus-openAPI", version.ref = "xodus" }
xodus-environment = { module = "org.jetbrains.xodus:xodus-environment", version.ref = "xodus" }
xodus-vfs = { module = "org.jetbrains.xodus:xodus-vfs", version.ref = "xodus" }

View File

@@ -1,5 +1,6 @@
package app.termora
@Suppress("CascadeIf")
class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
init {
generalOption.portTextField.value = host.port
@@ -13,6 +14,8 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
generalOption.passwordTextField.text = host.authentication.password
} else if (host.authentication.type == AuthenticationType.PublicKey) {
generalOption.publicKeyComboBox.selectedItem = host.authentication.password
} else if (host.authentication.type == AuthenticationType.SSHAgent) {
generalOption.sshAgentComboBox.selectedItem = host.authentication.password
}
proxyOption.proxyTypeComboBox.selectedItem = host.proxy.type

View File

@@ -38,6 +38,7 @@ enum class AuthenticationType {
No,
Password,
PublicKey,
SSHAgent,
KeyboardInteractive,
}

View File

@@ -6,6 +6,7 @@ import com.fazecast.jSerialComm.SerialPort
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatComboBox
import com.formdev.flatlaf.ui.FlatTextBorder
import com.formdev.flatlaf.util.SystemInfo
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import kotlinx.coroutines.Dispatchers
@@ -14,6 +15,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import org.apache.commons.lang3.StringUtils
import org.eclipse.jgit.internal.transport.sshd.agent.connector.PageantConnector
import org.eclipse.jgit.internal.transport.sshd.agent.connector.UnixDomainSocketConnector
import org.eclipse.jgit.internal.transport.sshd.agent.connector.WinPipeConnector
import java.awt.*
import java.awt.event.*
import java.nio.charset.Charset
@@ -21,7 +25,7 @@ import javax.swing.*
import javax.swing.table.DefaultTableCellRenderer
import javax.swing.table.DefaultTableModel
@Suppress("CascadeIf")
open class HostOptionsPane : OptionsPane() {
protected val tunnelingOption = TunnelingOption()
protected val generalOption = GeneralOption()
@@ -52,18 +56,23 @@ open class HostOptionsPane : OptionsPane() {
val port = (generalOption.portTextField.value ?: 22) as Int
var authentication = Authentication.No
var proxy = Proxy.No
val authenticationType = generalOption.authenticationTypeComboBox.selectedItem as AuthenticationType
if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.Password) {
if (authenticationType == AuthenticationType.Password) {
authentication = authentication.copy(
type = AuthenticationType.Password,
type = authenticationType,
password = String(generalOption.passwordTextField.password)
)
} else if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.PublicKey) {
} else if (authenticationType == AuthenticationType.PublicKey) {
authentication = authentication.copy(
type = AuthenticationType.PublicKey,
type = authenticationType,
password = generalOption.publicKeyComboBox.selectedItem?.toString() ?: StringUtils.EMPTY
)
} else if (authenticationType == AuthenticationType.SSHAgent) {
authentication = authentication.copy(
type = authenticationType,
password = generalOption.sshAgentComboBox.selectedItem?.toString() ?: StringUtils.EMPTY
)
}
if (proxyOption.proxyTypeComboBox.selectedItem != ProxyType.No) {
@@ -200,6 +209,7 @@ open class HostOptionsPane : OptionsPane() {
private val passwordPanel = JPanel(BorderLayout())
private val chooseKeyBtn = JButton(Icons.greyKey)
val passwordTextField = OutlinePasswordField(255)
val sshAgentComboBox = OutlineComboBox<String>()
val publicKeyComboBox = OutlineComboBox<String>()
val remarkTextArea = FixedLengthTextArea(512)
val authenticationTypeComboBox = FlatComboBox<AuthenticationType>()
@@ -215,6 +225,10 @@ open class HostOptionsPane : OptionsPane() {
publicKeyComboBox.isEditable = false
chooseKeyBtn.isFocusable = false
// 只有 Windows 允许修改
sshAgentComboBox.isEditable = SystemInfo.isWindows
sshAgentComboBox.isEnabled = SystemInfo.isWindows
protocolTypeComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
@@ -294,6 +308,17 @@ open class HostOptionsPane : OptionsPane() {
authenticationTypeComboBox.addItem(AuthenticationType.No)
authenticationTypeComboBox.addItem(AuthenticationType.Password)
authenticationTypeComboBox.addItem(AuthenticationType.PublicKey)
authenticationTypeComboBox.addItem(AuthenticationType.SSHAgent)
if (SystemInfo.isWindows) {
// 不要修改 addItem 的顺序,因为第一个是默认的
sshAgentComboBox.addItem(PageantConnector.DESCRIPTOR.identityAgent)
sshAgentComboBox.addItem(WinPipeConnector.DESCRIPTOR.identityAgent)
sshAgentComboBox.placeholderText = PageantConnector.DESCRIPTOR.identityAgent
} else {
sshAgentComboBox.addItem(UnixDomainSocketConnector.DESCRIPTOR.identityAgent)
sshAgentComboBox.placeholderText = UnixDomainSocketConnector.DESCRIPTOR.identityAgent
}
authenticationTypeComboBox.selectedItem = AuthenticationType.Password
@@ -457,6 +482,8 @@ open class HostOptionsPane : OptionsPane() {
.add(chooseKeyBtn).xy(3, 1)
.build(), BorderLayout.CENTER
)
} else if (authenticationTypeComboBox.selectedItem == AuthenticationType.SSHAgent) {
passwordPanel.add(sshAgentComboBox, BorderLayout.CENTER)
} else {
passwordPanel.add(passwordTextField, BorderLayout.CENTER)
}

View File

@@ -16,7 +16,7 @@ import kotlin.math.max
class RequestAuthenticationDialog(owner: Window, host: Host) : DialogWrapper(owner) {
private val authenticationTypeComboBox = FlatComboBox<AuthenticationType>()
private val rememberCheckBox = JCheckBox("Remember")
private val rememberCheckBox = JCheckBox(I18n.getString("termora.new-host.general.remember"))
private val passwordPanel = JPanel(BorderLayout())
private val passwordPasswordField = OutlinePasswordField()
private val usernameTextField = OutlineTextField()

View File

@@ -1,14 +1,17 @@
package app.termora
import app.termora.keyboardinteractive.TerminalUserInteraction
import app.termora.keymgr.KeyManager
import app.termora.keymgr.OhKeyPairKeyPairProvider
import app.termora.terminal.TerminalSize
import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.util.FontUtils
import com.formdev.flatlaf.util.SystemInfo
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.apache.sshd.client.ClientBuilder
import org.apache.sshd.client.SshClient
import org.apache.sshd.client.auth.password.PasswordIdentityProvider
import org.apache.sshd.client.auth.password.UserAuthPasswordFactory
import org.apache.sshd.client.channel.ChannelShell
import org.apache.sshd.client.channel.ClientChannelEvent
@@ -21,23 +24,30 @@ import org.apache.sshd.client.keyverifier.ModifiedServerKeyAcceptor
import org.apache.sshd.client.keyverifier.ServerKeyVerifier
import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.common.AttributeRepository
import org.apache.sshd.common.SshConstants
import org.apache.sshd.common.SshException
import org.apache.sshd.common.channel.PtyChannelConfiguration
import org.apache.sshd.common.config.keys.KeyRandomArt
import org.apache.sshd.common.config.keys.KeyUtils
import org.apache.sshd.common.global.KeepAliveHandler
import org.apache.sshd.common.kex.BuiltinDHFactories
import org.apache.sshd.common.keyprovider.KeyIdentityProvider
import org.apache.sshd.common.session.SessionContext
import org.apache.sshd.common.util.net.SshdSocketAddress
import org.apache.sshd.core.CoreModuleProperties
import org.apache.sshd.server.forward.AcceptAllForwardingFilter
import org.apache.sshd.server.forward.RejectAllForwardingFilter
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
import org.eclipse.jgit.internal.transport.sshd.JGitSshClient
import org.eclipse.jgit.internal.transport.sshd.agent.JGitSshAgentFactory
import org.eclipse.jgit.internal.transport.sshd.agent.connector.PageantConnector
import org.eclipse.jgit.internal.transport.sshd.agent.connector.UnixDomainSocketConnector
import org.eclipse.jgit.transport.CredentialsProvider
import org.eclipse.jgit.transport.SshConstants.IDENTITY_AGENT
import org.eclipse.jgit.transport.sshd.IdentityPasswordProvider
import org.eclipse.jgit.transport.sshd.ProxyData
import org.eclipse.jgit.transport.sshd.agent.ConnectorFactory
import org.slf4j.LoggerFactory
import java.awt.Font
import java.awt.Window
import java.io.ByteArrayOutputStream
import java.net.InetSocketAddress
@@ -45,15 +55,15 @@ import java.net.Proxy
import java.net.SocketAddress
import java.nio.file.Path
import java.nio.file.Paths
import java.security.KeyPair
import java.security.PublicKey
import java.time.Duration
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.JOptionPane
import javax.swing.SwingUtilities
import java.util.concurrent.atomic.AtomicReference
import javax.swing.*
import kotlin.math.max
@Suppress("CascadeIf")
object SshClients {
val HOST_KEY = AttributeRepository.AttributeKey<Host>()
@@ -190,6 +200,16 @@ object SshClients {
entry.hostName = host.host
entry.setProperty("Middleware", middleware.toString())
// ssh-agent
if (host.authentication.type == AuthenticationType.SSHAgent) {
if (host.authentication.password.isNotBlank())
entry.setProperty(IDENTITY_AGENT, host.authentication.password)
else if (SystemInfo.isWindows)
entry.setProperty(IDENTITY_AGENT, PageantConnector.DESCRIPTOR.identityAgent)
else
entry.setProperty(IDENTITY_AGENT, UnixDomainSocketConnector.DESCRIPTOR.identityAgent)
}
val session = client.connect(entry).verify(timeout).session
if (host.authentication.type == AuthenticationType.Password) {
session.addPasswordIdentity(host.authentication.password)
@@ -197,22 +217,17 @@ object SshClients {
session.keyIdentityProvider = OhKeyPairKeyPairProvider(host.authentication.password)
}
val owner = client.properties["owner"] as Window?
if (owner != null) {
val identityProvider = IdentityProvider(host, owner)
session.passwordIdentityProvider = identityProvider
val combinedKeyIdentityProvider = CombinedKeyIdentityProvider()
if (session.keyIdentityProvider != null) {
combinedKeyIdentityProvider.addKeyKeyIdentityProvider(session.keyIdentityProvider)
}
combinedKeyIdentityProvider.addKeyKeyIdentityProvider(identityProvider)
session.keyIdentityProvider = combinedKeyIdentityProvider
}
val verifyTimeout = Duration.ofSeconds(timeout.seconds * 5)
if (!session.auth().verify(verifyTimeout).await(verifyTimeout)) {
try {
if (!session.auth().verify(timeout).await(timeout)) {
throw SshException("Authentication failed")
}
} catch (e: Exception) {
if (e !is SshException || e.disconnectCode != SshConstants.SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE) throw e
val owner = client.properties["owner"] as Window? ?: throw e
val authentication = ask(host, owner) ?: throw e
if (authentication.type == AuthenticationType.No) throw e
return doOpenSession(host.copy(authentication = authentication), client)
}
session.setAttribute(HOST_KEY, host)
@@ -299,7 +314,11 @@ object SshClients {
sshClient.keyIdentityProvider = KeyIdentityProvider { mutableListOf() }
// 设置优先级
if (host.authentication.type == AuthenticationType.PublicKey) {
if (host.authentication.type == AuthenticationType.PublicKey || host.authentication.type == AuthenticationType.SSHAgent) {
if (host.authentication.type == AuthenticationType.SSHAgent) {
// ssh-agent
sshClient.agentFactory = JGitSshAgentFactory(ConnectorFactory.getDefault(), null)
}
CoreModuleProperties.PREFERRED_AUTHS.set(
sshClient,
listOf(
@@ -350,6 +369,24 @@ object SshClients {
return sshClient
}
private fun ask(host: Host, owner: Window): Authentication? {
val ref = AtomicReference<Authentication>(null)
SwingUtilities.invokeAndWait {
val dialog = RequestAuthenticationDialog(owner, host)
dialog.setLocationRelativeTo(owner)
val authentication = dialog.getAuthentication().apply { ref.set(this) }
// save
if (dialog.isRemembered()) {
hostManager.addHost(
host.copy(
authentication = authentication,
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
)
)
}
}
return ref.get()
}
private class MyDialogServerKeyVerifier(private val owner: Window) : ServerKeyVerifier, ModifiedServerKeyAcceptor {
override fun verifyServerKey(
@@ -368,26 +405,69 @@ object SshClients {
actual: PublicKey?
): Boolean {
val result = AtomicBoolean(false)
SwingUtilities.invokeAndWait {
result.set(
OptionPane.showConfirmDialog(
parentComponent = owner,
message = I18n.getString(
"termora.host.modified-server-key",
remoteAddress.toString().replace("/", StringUtils.EMPTY),
KeyUtils.getKeyType(expected),
KeyUtils.getFingerPrint(expected),
KeyUtils.getKeyType(actual),
KeyUtils.getFingerPrint(actual),
),
optionType = JOptionPane.OK_CANCEL_OPTION,
messageType = JOptionPane.WARNING_MESSAGE,
) == JOptionPane.OK_OPTION
)
SwingUtilities.invokeAndWait { result.set(ask(remoteAddress, expected, actual) == JOptionPane.OK_OPTION) }
return result.get()
}
return result.get()
private fun ask(
remoteAddress: SocketAddress?,
expected: PublicKey?,
actual: PublicKey?
): Int {
val formMargin = "7dlu"
val layout = FormLayout(
"default:grow",
"pref, 12dlu, pref, 4dlu, pref, 2dlu, pref, $formMargin, pref, $formMargin, pref, pref, 12dlu, pref"
)
val errorColor = if (FlatLaf.isLafDark()) UIManager.getColor("Component.warning.focusedBorderColor") else
UIManager.getColor("Component.error.focusedBorderColor")
val font = FontUtils.getCompositeFont("JetBrains Mono", Font.PLAIN, 12)
val artBox = Box.createHorizontalBox()
artBox.add(Box.createHorizontalGlue())
val expectedBox = Box.createVerticalBox()
for (line in KeyRandomArt(expected).toString().lines()) {
val label = JLabel(line)
label.font = font
expectedBox.add(label)
}
artBox.add(expectedBox)
artBox.add(Box.createHorizontalGlue())
val actualBox = Box.createVerticalBox()
for (line in KeyRandomArt(actual).toString().lines()) {
val label = JLabel(line)
label.foreground = errorColor
label.font = font
actualBox.add(label)
}
artBox.add(actualBox)
artBox.add(Box.createHorizontalGlue())
var rows = 1
val step = 2
// @formatter:off
val address = remoteAddress.toString().replace("/", StringUtils.EMPTY)
val panel = FormBuilder.create().layout(layout)
.add("<html><b>${I18n.getString("termora.host.modified-server-key.title", address)}</b></html>").xy(1, rows).apply { rows += step }
.add("${I18n.getString("termora.host.modified-server-key.thumbprint")}:").xy(1, rows).apply { rows += step }
.add(" ${I18n.getString("termora.host.modified-server-key.expected")}: ${KeyUtils.getFingerPrint(expected)}").xy(1, rows).apply { rows += step }
.add("<html>&nbsp;&nbsp;${I18n.getString("termora.host.modified-server-key.actual")}: <font color=rgb(${errorColor.red},${errorColor.green},${errorColor.blue})>${KeyUtils.getFingerPrint(actual)}</font></html>").xy(1, rows).apply { rows += step }
.addSeparator(StringUtils.EMPTY).xy(1, rows).apply { rows += step }
.add(artBox).xy(1, rows).apply { rows += step }
.addSeparator(StringUtils.EMPTY).xy(1, rows).apply { rows += 1 }
.add(I18n.getString("termora.host.modified-server-key.are-you-sure")).xy(1, rows).apply { rows += step }
.build()
// @formatter:on
return OptionPane.showConfirmDialog(
owner,
panel,
"SSH Security Warning",
messageType = JOptionPane.WARNING_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION
)
}
}
@@ -417,55 +497,5 @@ object SshClients {
}
}
private class IdentityProvider(private val host: Host, private val owner: Window) : PasswordIdentityProvider,
KeyIdentityProvider {
private val asked = AtomicBoolean(false)
private val hostManager get() = HostManager.getInstance()
private val keyManager get() = KeyManager.getInstance()
private var authentication = Authentication.No
override fun loadPasswords(session: SessionContext): MutableIterable<String> {
val authentication = ask()
if (authentication.type != AuthenticationType.Password) {
return mutableListOf()
}
return mutableListOf(authentication.password)
}
override fun loadKeys(session: SessionContext): MutableIterable<KeyPair> {
val authentication = ask()
if (authentication.type != AuthenticationType.PublicKey) {
return mutableListOf()
}
val ohKeyPair = keyManager.getOhKeyPair(authentication.password) ?: return mutableListOf()
return mutableListOf(OhKeyPairKeyPairProvider.generateKeyPair(ohKeyPair))
}
private fun ask(): Authentication {
if (asked.compareAndSet(false, true)) {
askNow()
}
return authentication
}
private fun askNow() {
if (SwingUtilities.isEventDispatchThread()) {
val dialog = RequestAuthenticationDialog(owner, host)
dialog.setLocationRelativeTo(owner)
authentication = dialog.getAuthentication()
// save
if (dialog.isRemembered()) {
val host = host.copy(
authentication = authentication,
username = dialog.getUsername(), updateDate = System.currentTimeMillis(),
)
hostManager.addHost(host)
}
} else {
SwingUtilities.invokeAndWait { askNow() }
}
}
}
}

View File

@@ -51,7 +51,7 @@ class OutlineTextArea : FlatTextArea() {
}
}
class OutlineComboBox<T> : JComboBox<T>() {
class OutlineComboBox<T> : FlatComboBox<T>() {
init {
addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {

View File

@@ -39,7 +39,11 @@ termora.doorman.mnemonic.incorrect=Incorrect mnemonic
# Hosts
termora.host.modified-server-key=HOST [{0}] IDENTIFICATION HAS CHANGED<br/><br/>Expected: {1} key fingerprint is {2}<br/><br/>Actual: {3} key fingerprint is {4}<br/><br/>Are you sure you want to continue connecting?
termora.host.modified-server-key.title=HOST [{0}] IDENTIFICATION HAS CHANGED
termora.host.modified-server-key.thumbprint=Host key thumbprint
termora.host.modified-server-key.expected=Expected
termora.host.modified-server-key.actual=Actual
termora.host.modified-server-key.are-you-sure=Are you sure you want to continue connecting?
# Settings
@@ -161,6 +165,7 @@ termora.new-host.general.username=Username
termora.new-host.general.authentication=Authentication
termora.new-host.general.password=Password
termora.new-host.general.remark=Comment
termora.new-host.general.remember=Remember
termora.new-host.proxy=Proxy

View File

@@ -37,8 +37,11 @@ termora.doorman.mnemonic.incorrect=助记词错误
# Hosts
termora.host.modified-server-key=主机 [{0}] 身份已发生变化<br/><br/>期待: {1} 的指纹 {2}<br/><br/>实际: {3} 的指纹 {4}<br/><br/>你确定要继续连接吗?
termora.host.modified-server-key.title=主机 [{0}] 身份已发生变化
termora.host.modified-server-key.thumbprint=主机密钥指纹
termora.host.modified-server-key.expected=期待
termora.host.modified-server-key.actual=实际
termora.host.modified-server-key.are-you-sure=你确定要继续连接吗?
termora.setting=设置
termora.settings.restart.title=重启
@@ -148,6 +151,7 @@ termora.new-host.general.username=用户名
termora.new-host.general.authentication=认证类型
termora.new-host.general.password=密码
termora.new-host.general.remark=备注
termora.new-host.general.remember=记住
termora.new-host.proxy=代理
termora.new-host.terminal=${termora.settings.terminal}

View File

@@ -38,8 +38,11 @@ termora.doorman.mnemonic.incorrect=助記詞錯誤
# Hosts
termora.host.modified-server-key=主機 [{0}] 身分已變更<br/><br/>期待: {1} 的指紋 {2}<br/><br/>實際: {3} 的指紋 {4}<br/><br/>你確定要繼續連線嗎?
termora.host.modified-server-key.title=主機 [{0}] 身分已變更
termora.host.modified-server-key.thumbprint=主機密鑰指紋
termora.host.modified-server-key.expected=期待
termora.host.modified-server-key.actual=實際
termora.host.modified-server-key.are-you-sure=你確定要繼續連線嗎?
termora.setting=設定
termora.settings.restart.title=重啟
@@ -147,6 +150,7 @@ termora.new-host.general.username=用戶名
termora.new-host.general.authentication=認證類型
termora.new-host.general.password=密碼
termora.new-host.general.remark=備註
termora.new-host.general.remember=記住
termora.new-host.proxy=代理
termora.new-host.terminal=${termora.settings.terminal}