diff --git a/THIRDPARTY b/THIRDPARTY index c40d86d..965e9a1 100644 --- a/THIRDPARTY +++ b/THIRDPARTY @@ -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 diff --git a/build.gradle.kts b/build.gradle.kts index 99c1912..a3b8b3d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index da2d482..0c48305 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/src/main/kotlin/app/termora/EditHostOptionsPane.kt b/src/main/kotlin/app/termora/EditHostOptionsPane.kt index 762b7e7..4b314a0 100644 --- a/src/main/kotlin/app/termora/EditHostOptionsPane.kt +++ b/src/main/kotlin/app/termora/EditHostOptionsPane.kt @@ -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 diff --git a/src/main/kotlin/app/termora/Host.kt b/src/main/kotlin/app/termora/Host.kt index a258c32..a338f8b 100644 --- a/src/main/kotlin/app/termora/Host.kt +++ b/src/main/kotlin/app/termora/Host.kt @@ -38,6 +38,7 @@ enum class AuthenticationType { No, Password, PublicKey, + SSHAgent, KeyboardInteractive, } diff --git a/src/main/kotlin/app/termora/HostOptionsPane.kt b/src/main/kotlin/app/termora/HostOptionsPane.kt index 3b61056..2476574 100644 --- a/src/main/kotlin/app/termora/HostOptionsPane.kt +++ b/src/main/kotlin/app/termora/HostOptionsPane.kt @@ -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() val publicKeyComboBox = OutlineComboBox() val remarkTextArea = FixedLengthTextArea(512) val authenticationTypeComboBox = FlatComboBox() @@ -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) } diff --git a/src/main/kotlin/app/termora/RequestAuthenticationDialog.kt b/src/main/kotlin/app/termora/RequestAuthenticationDialog.kt index 1edf8b7..50dadcd 100644 --- a/src/main/kotlin/app/termora/RequestAuthenticationDialog.kt +++ b/src/main/kotlin/app/termora/RequestAuthenticationDialog.kt @@ -16,7 +16,7 @@ import kotlin.math.max class RequestAuthenticationDialog(owner: Window, host: Host) : DialogWrapper(owner) { private val authenticationTypeComboBox = FlatComboBox() - 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() diff --git a/src/main/kotlin/app/termora/SshClients.kt b/src/main/kotlin/app/termora/SshClients.kt index 43ebe20..5dfb8fd 100644 --- a/src/main/kotlin/app/termora/SshClients.kt +++ b/src/main/kotlin/app/termora/SshClients.kt @@ -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() @@ -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,21 +217,16 @@ 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) + try { + if (!session.auth().verify(timeout).await(timeout)) { + throw SshException("Authentication failed") } - combinedKeyIdentityProvider.addKeyKeyIdentityProvider(identityProvider) - session.keyIdentityProvider = combinedKeyIdentityProvider - } - - val verifyTimeout = Duration.ofSeconds(timeout.seconds * 5) - if (!session.auth().verify(verifyTimeout).await(verifyTimeout)) { - 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(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,27 +405,70 @@ 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() } + + 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("${I18n.getString("termora.host.modified-server-key.title", address)}").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("  ${I18n.getString("termora.host.modified-server-key.actual")}: ${KeyUtils.getFingerPrint(actual)}").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 + ) + + } } private class DialogServerKeyVerifier( @@ -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 { - val authentication = ask() - if (authentication.type != AuthenticationType.Password) { - return mutableListOf() - } - return mutableListOf(authentication.password) - } - - override fun loadKeys(session: SessionContext): MutableIterable { - 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() } - } - } - } } diff --git a/src/main/kotlin/app/termora/TextField.kt b/src/main/kotlin/app/termora/TextField.kt index 3c61e06..e9c7f8f 100644 --- a/src/main/kotlin/app/termora/TextField.kt +++ b/src/main/kotlin/app/termora/TextField.kt @@ -51,7 +51,7 @@ class OutlineTextArea : FlatTextArea() { } } -class OutlineComboBox : JComboBox() { +class OutlineComboBox : FlatComboBox() { init { addItemListener { if (it.stateChange == ItemEvent.SELECTED) { diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 5ff4757..baabc60 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -39,7 +39,11 @@ termora.doorman.mnemonic.incorrect=Incorrect mnemonic # Hosts -termora.host.modified-server-key=HOST [{0}] IDENTIFICATION HAS CHANGED

Expected: {1} key fingerprint is {2}

Actual: {3} key fingerprint is {4}

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 diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index 53194c2..95d40b7 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -37,8 +37,11 @@ termora.doorman.mnemonic.incorrect=助记词错误 # Hosts -termora.host.modified-server-key=主机 [{0}] 身份已发生变化

期待: {1} 的指纹 {2}

实际: {3} 的指纹 {4}

你确定要继续连接吗? - +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} diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index 6e939fd..8393f57 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -38,8 +38,11 @@ termora.doorman.mnemonic.incorrect=助記詞錯誤 # Hosts -termora.host.modified-server-key=主機 [{0}] 身分已變更

期待: {1} 的指紋 {2}

實際: {3} 的指紋 {4}

你確定要繼續連線嗎? - +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}