diff --git a/src/main/kotlin/app/termora/EditHostOptionsPane.kt b/src/main/kotlin/app/termora/EditHostOptionsPane.kt index 4b314a0..e8b6f58 100644 --- a/src/main/kotlin/app/termora/EditHostOptionsPane.kt +++ b/src/main/kotlin/app/termora/EditHostOptionsPane.kt @@ -1,5 +1,7 @@ package app.termora +import org.apache.commons.lang3.StringUtils + @Suppress("CascadeIf") class EditHostOptionsPane(private val host: Host) : HostOptionsPane() { init { @@ -31,6 +33,8 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() { terminalOption.heartbeatIntervalTextField.value = host.options.heartbeatInterval tunnelingOption.tunnelings.addAll(host.tunnelings) + tunnelingOption.x11ForwardingCheckBox.isSelected = host.options.enableX11Forwarding + tunnelingOption.x11ServerTextField.text = StringUtils.defaultIfBlank(host.options.x11Forwarding, "localhost:0") if (host.options.jumpHosts.isNotEmpty()) { val hosts = HostManager.getInstance().hosts().associateBy { it.id } diff --git a/src/main/kotlin/app/termora/Host.kt b/src/main/kotlin/app/termora/Host.kt index a338f8b..8dfc491 100644 --- a/src/main/kotlin/app/termora/Host.kt +++ b/src/main/kotlin/app/termora/Host.kt @@ -138,6 +138,16 @@ data class Options( * SFTP 默认目录 */ val sftpDefaultDirectory: String = StringUtils.EMPTY, + + /** + * X11 Forwarding + */ + val enableX11Forwarding: Boolean = false, + + /** + * X11 Server,Format: host.port. default: localhost:0 + */ + val x11Forwarding: String = StringUtils.EMPTY, ) { companion object { val Default = Options() diff --git a/src/main/kotlin/app/termora/HostOptionsPane.kt b/src/main/kotlin/app/termora/HostOptionsPane.kt index 2476574..c727a3b 100644 --- a/src/main/kotlin/app/termora/HostOptionsPane.kt +++ b/src/main/kotlin/app/termora/HostOptionsPane.kt @@ -103,7 +103,9 @@ open class HostOptionsPane : OptionsPane() { heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int, jumpHosts = jumpHostsOption.jumpHosts.map { it.id }, serialComm = serialComm, - sftpDefaultDirectory = sftpOption.defaultDirectoryField.text + sftpDefaultDirectory = sftpOption.defaultDirectoryField.text, + enableX11Forwarding = tunnelingOption.x11ForwardingCheckBox.isSelected, + x11Forwarding = tunnelingOption.x11ServerTextField.text, ) return Host( @@ -169,6 +171,17 @@ open class HostOptionsPane : OptionsPane() { } } + // tunnel + if (tunnelingOption.x11ForwardingCheckBox.isSelected) { + if (validateField(tunnelingOption.x11ServerTextField)) { + return false + } + val segments = tunnelingOption.x11ServerTextField.text.split(":") + if (segments.size != 2 || segments[1].toIntOrNull() == null) { + setOutlineError(tunnelingOption.x11ServerTextField) + return false + } + } return true } @@ -178,14 +191,18 @@ open class HostOptionsPane : OptionsPane() { */ private fun validateField(textField: JTextField): Boolean { if (textField.isEnabled && textField.text.isBlank()) { - selectOptionJComponent(textField) - textField.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR) - textField.requestFocusInWindow() + setOutlineError(textField) return true } return false } + private fun setOutlineError(textField: JTextField) { + selectOptionJComponent(textField) + textField.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR) + textField.requestFocusInWindow() + } + /** * 返回 true 表示有错误 */ @@ -749,6 +766,8 @@ open class HostOptionsPane : OptionsPane() { protected inner class TunnelingOption : JPanel(BorderLayout()), Option { val tunnelings = mutableListOf() + val x11ForwardingCheckBox = JCheckBox("X DISPLAY:") + val x11ServerTextField = OutlineTextField(255) private val model = object : DefaultTableModel() { override fun getRowCount(): Int { @@ -823,13 +842,36 @@ open class HostOptionsPane : OptionsPane() { box.add(Box.createHorizontalStrut(4)) box.add(deleteBtn) - add(JLabel("TCP/IP Forwarding:"), BorderLayout.NORTH) - add(scrollPane, BorderLayout.CENTER) - add(box, BorderLayout.SOUTH) + x11ForwardingCheckBox.isFocusable = false + + if (x11ServerTextField.text.isBlank()) { + x11ServerTextField.text = "localhost:0" + } + + val x11Forwarding = Box.createHorizontalBox() + x11Forwarding.border = BorderFactory.createCompoundBorder( + BorderFactory.createTitledBorder("X11 Forwarding"), + BorderFactory.createEmptyBorder(4, 4, 4, 4) + ) + x11Forwarding.add(x11ForwardingCheckBox) + x11Forwarding.add(x11ServerTextField) + + x11ServerTextField.isEnabled = x11ForwardingCheckBox.isSelected + + val panel = JPanel(BorderLayout()) + panel.add(JLabel("TCP/IP Forwarding:"), BorderLayout.NORTH) + panel.add(scrollPane, BorderLayout.CENTER) + panel.add(box, BorderLayout.SOUTH) + panel.border = BorderFactory.createEmptyBorder(0, 0, 8, 0) + + add(panel, BorderLayout.CENTER) + add(x11Forwarding, BorderLayout.SOUTH) } private fun initEvents() { + x11ForwardingCheckBox.addChangeListener { x11ServerTextField.isEnabled = x11ForwardingCheckBox.isSelected } + addBtn.addActionListener(object : AbstractAction() { override fun actionPerformed(e: ActionEvent?) { val dialog = PortForwardingDialog(SwingUtilities.getWindowAncestor(this@HostOptionsPane)) diff --git a/src/main/kotlin/app/termora/SshClients.kt b/src/main/kotlin/app/termora/SshClients.kt index bcae9a9..f378d62 100644 --- a/src/main/kotlin/app/termora/SshClients.kt +++ b/src/main/kotlin/app/termora/SshClients.kt @@ -3,6 +3,8 @@ package app.termora import app.termora.keyboardinteractive.TerminalUserInteraction import app.termora.keymgr.OhKeyPairKeyPairProvider import app.termora.terminal.TerminalSize +import app.termora.x11.ChannelX11 +import app.termora.x11.X11ChannelFactory import com.formdev.flatlaf.FlatLaf import com.formdev.flatlaf.util.FontUtils import com.formdev.flatlaf.util.SystemInfo @@ -29,7 +31,10 @@ import org.apache.sshd.client.session.SessionFactory import org.apache.sshd.common.AttributeRepository import org.apache.sshd.common.SshConstants import org.apache.sshd.common.SshException +import org.apache.sshd.common.channel.ChannelFactory import org.apache.sshd.common.channel.PtyChannelConfiguration +import org.apache.sshd.common.channel.PtyChannelConfigurationHolder +import org.apache.sshd.common.cipher.CipherNone import org.apache.sshd.common.config.keys.KeyRandomArt import org.apache.sshd.common.config.keys.KeyUtils import org.apache.sshd.common.future.CloseFuture @@ -75,6 +80,7 @@ import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import javax.swing.* import kotlin.math.max +import kotlin.random.Random @Suppress("CascadeIf") object SshClients { @@ -234,6 +240,18 @@ object SshClients { session.keyIdentityProvider = OhKeyPairKeyPairProvider(host.authentication.password) } + if (host.options.enableX11Forwarding) { + val segments = host.options.x11Forwarding.split(":") + if (segments.size == 2) { + val x11Host = segments[0] + val x11Port = segments[1].toIntOrNull() + if (x11Port != null) { + CoreModuleProperties.X11_BIND_HOST.set(session, x11Host) + CoreModuleProperties.X11_BASE_PORT.set(session, 6000 + x11Port) + } + } + } + try { if (!session.auth().verify(timeout).await(timeout)) { throw SshException("Authentication failed") @@ -325,6 +343,11 @@ object SshClients { builder.hostConfigEntryResolver(HostConfigEntryResolver.EMPTY) + val channelFactories = mutableListOf() + channelFactories.addAll(ClientBuilder.DEFAULT_CHANNEL_FACTORIES) + channelFactories.add(X11ChannelFactory.INSTANCE) + builder.channelFactories(channelFactories) + val sshClient = builder.build() as JGitSshClient // https://github.com/TermoraDev/termora/issues/180 @@ -533,6 +556,21 @@ object SshClients { return clientProxyConnector } + + override fun createShellChannel( + ptyConfig: PtyChannelConfigurationHolder?, + env: MutableMap? + ): ChannelShell { + if (inCipher is CipherNone || outCipher is CipherNone) + throw IllegalStateException("Interactive channels are not supported with none cipher") + val channel = MyChannelShell(ptyConfig, env) + val id = connectionService.registerChannel(channel) + if (log.isDebugEnabled) { + log.debug("createShellChannel({}) created id={} - PTY={}", this, id, ptyConfig) + } + return channel + } + } } } @@ -542,6 +580,63 @@ object SshClients { throw UnsupportedOperationException() } + private class MyChannelShell( + configHolder: PtyChannelConfigurationHolder?, + env: MutableMap? + ) : ChannelShell(configHolder, env) { + + override fun doOpenPty() { + val session = super.getSession() + val x11Host = CoreModuleProperties.X11_BIND_HOST.getOrNull(session) + val x11Port = CoreModuleProperties.X11_BASE_PORT.getOrNull(session) + + if (x11Port == null || x11Host == null) { + super.doOpenPty() + return + } + + val buffer = session.createBuffer(SshConstants.SSH_MSG_CHANNEL_REQUEST) + buffer.putInt(super.getRecipient()) + buffer.putString("x11-req") + buffer.putBoolean(false) // want-reply + buffer.putBoolean(false) + buffer.putString("MIT-MAGIC-COOKIE-1") + buffer.putBytes(getFakedCookie()) + buffer.putInt(0) + + writePacket(buffer) + + super.doOpenPty() + } + + private fun getFakedCookie(): ByteArray { + val session = super.getSession() + var cookie = ChannelX11.X11_COOKIE_HEX.getOrNull(session) + if (cookie != null) { + return cookie as ByteArray + } + + synchronized(session) { + cookie = ChannelX11.X11_COOKIE_HEX.getOrNull(session) + if (cookie != null) { + return cookie as ByteArray + } + + val foo = Random.nextBytes(16) + ChannelX11.X11_COOKIE.set(session, foo) + + val bar = foo.copyOf(32) + for (i in 0..15) { + bar[2 * i] = ChannelX11.COOKIE_TABLE[(foo[i].toInt() ushr 4) and 0xf] + bar[2 * i + 1] = ChannelX11.COOKIE_TABLE[foo[i].toInt() and 0xf] + } + ChannelX11.X11_COOKIE_HEX.set(session, bar) + + return bar + } + } + } + private class MyIoConnector(private val sshClient: MyJGitSshClient, private val ioConnector: IoConnector) : IoConnector { override fun close(immediately: Boolean): CloseFuture { diff --git a/src/main/kotlin/app/termora/x11/ChannelX11.kt b/src/main/kotlin/app/termora/x11/ChannelX11.kt new file mode 100644 index 0000000..15a582a --- /dev/null +++ b/src/main/kotlin/app/termora/x11/ChannelX11.kt @@ -0,0 +1,117 @@ +package app.termora.x11 + +import org.apache.sshd.client.channel.AbstractClientChannel +import org.apache.sshd.client.future.DefaultOpenFuture +import org.apache.sshd.client.future.OpenFuture +import org.apache.sshd.client.session.ClientConnectionService +import org.apache.sshd.common.Property +import org.apache.sshd.common.SshConstants +import org.apache.sshd.common.channel.ChannelOutputStream +import org.apache.sshd.common.io.IoConnectFuture +import org.apache.sshd.common.io.IoSession +import org.apache.sshd.common.util.buffer.Buffer +import org.apache.sshd.common.util.buffer.ByteArrayBuffer +import java.net.InetSocketAddress +import java.util.* +import java.util.concurrent.atomic.AtomicBoolean + +class ChannelX11( + private val host: String, + private val port: Int, +) : AbstractClientChannel("x11") { + + companion object { + val X11_COOKIE: Property = Property.`object`("x11-cookie") + val X11_COOKIE_HEX: Property = Property.`object`("x11-cookie-hex") + val COOKIE_TABLE = byteArrayOf( + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x61, + 0x62, 0x63, 0x64, 0x65, 0x66 + ) + } + + private lateinit var x11: IoSession + private val isInitialized = AtomicBoolean(false) + + override fun open(recipient: Long, rwSize: Long, packetSize: Long, buffer: Buffer): OpenFuture { + val openFuture = DefaultOpenFuture(this, futureLock).apply { openFuture = this } + + connectX11Server().addListener { + if (it.isConnected) { + this.x11 = it.session + handleOpenSuccess(recipient, rwSize, packetSize, buffer) + } else { + if (it.exception != null) { + openFuture.exception = it.exception + } else { + openFuture.value = false + } + unregisterSelf() + } + } + + return openFuture + } + + override fun doOpen() { + this.out = ChannelOutputStream( + this, remoteWindow, log, + SshConstants.SSH_MSG_CHANNEL_DATA, true + ) + } + + private fun connectX11Server(): IoConnectFuture { + val connector = session.factoryManager.ioServiceFactory.createConnector(X11IoHandler(this)) + val future = connector.connect(InetSocketAddress(host, port), session, null) + addCloseFutureListener { if (it.isClosed) connector.close(true) } + return future + } + + + override fun doWriteData(data: ByteArray, off: Int, len: Long) { + if (isInitialized.compareAndSet(false, true)) { + val cookie = X11_COOKIE.getOrNull(session) ?: return + val foo = data.copyOfRange(off, off + len.toInt()) + val s = 0 + val l = foo.size + if (l < 9) return + + var plen = (foo[s + 6].toInt() and 0xff) * 256 + (foo[s + 7].toInt() and 0xff) + var dlen = (foo[s + 8].toInt() and 0xff) * 256 + (foo[s + 9].toInt() and 0xff) + if ((foo[s].toInt() and 0xff) == 0x6c) { + plen = ((plen ushr 8) and 0xff) or ((plen shl 8) and 0xff00) + dlen = ((dlen ushr 8) and 0xff) or ((dlen shl 8) and 0xff00) + } + + if (l < 12 + plen + ((-plen) and 3) + dlen) return + + val bar = ByteArray(dlen) + System.arraycopy(foo, s + 12 + plen + ((-plen) and 3), bar, 0, dlen) + + if (Objects.deepEquals(cookie, bar) && x11.isOpen) { + x11.writeBuffer(ByteArrayBuffer(foo, s, l)) + } else { + sendEof() + } + } else if (x11.isOpen) { + x11.writeBuffer(ByteArrayBuffer(data, off, len.toInt())) + } + } + + override fun handleEof() { + super.handleEof() + close(true) + } + + private fun unregisterSelf() { + try { + session.getService(ClientConnectionService::class.java) + .unregisterChannel(this) + close(true) + } catch (e: Exception) { + if (log.isWarnEnabled) { + log.error(e.message, e) + } + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/x11/X11ChannelFactory.kt b/src/main/kotlin/app/termora/x11/X11ChannelFactory.kt new file mode 100644 index 0000000..a4621a3 --- /dev/null +++ b/src/main/kotlin/app/termora/x11/X11ChannelFactory.kt @@ -0,0 +1,24 @@ +package app.termora.x11 + +import org.apache.sshd.common.channel.Channel +import org.apache.sshd.common.channel.ChannelFactory +import org.apache.sshd.common.session.Session +import org.apache.sshd.core.CoreModuleProperties + +class X11ChannelFactory private constructor() : ChannelFactory { + companion object { + val INSTANCE = X11ChannelFactory() + } + + override fun getName(): String { + return "x11" + } + + override fun createChannel(session: Session): Channel? { + val x11Host = CoreModuleProperties.X11_BIND_HOST.getOrNull(session) + val x11Port = CoreModuleProperties.X11_BASE_PORT.getOrNull(session) + if (x11Port == null || x11Host == null) return null + return ChannelX11(x11Host, x11Port) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/x11/X11IoHandler.kt b/src/main/kotlin/app/termora/x11/X11IoHandler.kt new file mode 100644 index 0000000..799b97c --- /dev/null +++ b/src/main/kotlin/app/termora/x11/X11IoHandler.kt @@ -0,0 +1,33 @@ +package app.termora.x11 + +import org.apache.sshd.common.io.IoSession +import org.apache.sshd.common.session.helpers.AbstractSession +import org.apache.sshd.common.session.helpers.AbstractSessionIoHandler +import org.apache.sshd.common.util.Readable +import org.apache.sshd.common.util.io.IoUtils +import kotlin.math.min + +class X11IoHandler(private val x11: ChannelX11) : AbstractSessionIoHandler() { + + private val out get() = x11.out + + override fun sessionClosed(ioSession: IoSession) { + x11.close(true) + } + + override fun messageReceived(session: IoSession, message: Readable) { + val bytes = ByteArray(min(IoUtils.DEFAULT_COPY_SIZE, message.available())) + if (bytes.isEmpty()) return + while (message.available() > 0) { + val available = min(message.available(), bytes.size) + message.getRawBytes(bytes, 0, available) + out.write(bytes, 0, available) + } + out.flush() + } + + override fun createSession(ioSession: IoSession): AbstractSession { + return x11.session as AbstractSession + } + +} \ No newline at end of file diff --git a/src/test/kotlin/app/termora/SFTPTest.kt b/src/test/kotlin/app/termora/SFTPTest.kt index ec6e2f6..6b7de16 100644 --- a/src/test/kotlin/app/termora/SFTPTest.kt +++ b/src/test/kotlin/app/termora/SFTPTest.kt @@ -1,45 +1,15 @@ package app.termora import org.apache.sshd.sftp.client.impl.DefaultSftpClientFactory -import org.testcontainers.containers.GenericContainer import java.nio.file.Files -import kotlin.test.AfterTest -import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertTrue -class SFTPTest { - private val sftpContainer = GenericContainer("linuxserver/openssh-server") - .withEnv("PUID", "1000") - .withEnv("PGID", "1000") - .withEnv("TZ", "Etc/UTC") - .withEnv("SUDO_ACCESS", "true") - .withEnv("PASSWORD_ACCESS", "true") - .withEnv("USER_NAME", "foo") - .withEnv("USER_PASSWORD", "pass") - .withEnv("SUDO_ACCESS", "true") - .withExposedPorts(2222) +class SFTPTest : SSHDTest() { - @BeforeTest - fun setup() { - sftpContainer.start() - } - - @AfterTest - fun teardown() { - sftpContainer.stop() - } @Test fun test() { - val host = Host( - name = sftpContainer.containerName, - protocol = Protocol.SSH, - host = "127.0.0.1", - port = sftpContainer.getMappedPort(2222), - username = "foo", - authentication = Authentication.No.copy(type = AuthenticationType.Password, password = "pass"), - ) val client = SshClients.openClient(host) val session = SshClients.openSession(host, client) diff --git a/src/test/kotlin/app/termora/SSHDTest.kt b/src/test/kotlin/app/termora/SSHDTest.kt new file mode 100644 index 0000000..c7d2ac2 --- /dev/null +++ b/src/test/kotlin/app/termora/SSHDTest.kt @@ -0,0 +1,41 @@ +package app.termora + +import org.testcontainers.containers.GenericContainer +import kotlin.test.AfterTest +import kotlin.test.BeforeTest + + +abstract class SSHDTest { + protected val sshd: GenericContainer<*> = GenericContainer("sshd") + .withEnv("PUID", "1000") + .withEnv("PGID", "1000") + .withEnv("TZ", "Etc/UTC") + .withEnv("SUDO_ACCESS", "true") + .withEnv("PASSWORD_ACCESS", "true") + .withEnv("USER_NAME", "foo") + .withEnv("USER_PASSWORD", "pass") + .withEnv("SUDO_ACCESS", "true") + .withExposedPorts(2222) + + protected val host by lazy { + Host( + name = sshd.containerName, + protocol = Protocol.SSH, + host = "127.0.0.1", + port = sshd.getMappedPort(2222), + username = "foo", + authentication = Authentication.No.copy(type = AuthenticationType.Password, password = "pass"), + ) + } + + + @BeforeTest + fun setup() { + sshd.start() + } + + @AfterTest + fun teardown() { + sshd.stop() + } +} \ No newline at end of file diff --git a/src/test/resources/sshd/Dockerfile b/src/test/resources/sshd/Dockerfile index 9e4e2fb..ffb1b3c 100644 --- a/src/test/resources/sshd/Dockerfile +++ b/src/test/resources/sshd/Dockerfile @@ -1,6 +1,6 @@ FROM linuxserver/openssh-server RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ - && apk update && apk add wget gcc g++ git make zsh htop stress-ng inetutils-telnet xclock xorg-server xinit && wget https://ohse.de/uwe/releases/lrzsz-0.12.20.tar.gz \ + && apk update && apk add wget gcc g++ git make zsh htop stress-ng inetutils-telnet xclock xcalc xorg-server xinit && wget https://ohse.de/uwe/releases/lrzsz-0.12.20.tar.gz \ && tar -xf lrzsz-0.12.20.tar.gz && cd lrzsz-0.12.20 && ./configure && make && make install \ && ln -s /usr/local/bin/lrz /usr/local/bin/rz && ln -s /usr/local/bin/lsz /usr/local/bin/sz RUN sed -i 's/#AllowAgentForwarding yes/AllowAgentForwarding yes/g' /etc/ssh/sshd_config