feat: support X11 forwarding (#443)

This commit is contained in:
hstyi
2025-04-01 00:54:02 +08:00
committed by GitHub
parent 54044625ea
commit 054c4701d2
10 changed files with 375 additions and 39 deletions

View File

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

View File

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

View File

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

View File

@@ -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<ChannelFactory>()
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<String, *>?
): 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<String, *>?
) : 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 {

View File

@@ -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<Any> = Property.`object`("x11-cookie")
val X11_COOKIE_HEX: Property<Any> = 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)
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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