diff --git a/sshlib/api.txt b/sshlib/api.txt index f2dbb36..905488b 100644 --- a/sshlib/api.txt +++ b/sshlib/api.txt @@ -366,6 +366,37 @@ package org.connectbot.sshlib { property public String type; } + public sealed exhaustive interface SessionExit { + } + + public static final class SessionExit.Signal implements org.connectbot.sshlib.SessionExit { + ctor public SessionExit.Signal(java.lang.String signalName, boolean coreDumped, java.lang.String errorMessage); + method public java.lang.String component1(); + method public boolean component2(); + method public java.lang.String component3(); + method public org.connectbot.sshlib.SessionExit.Signal copy(optional java.lang.String signalName, optional boolean coreDumped, optional java.lang.String errorMessage); + method public boolean equals(java.lang.Object? other); + method @InaccessibleFromKotlin public boolean getCoreDumped(); + method @InaccessibleFromKotlin public java.lang.String getErrorMessage(); + method @InaccessibleFromKotlin public java.lang.String getSignalName(); + method public int hashCode(); + method public java.lang.String toString(); + property public boolean coreDumped; + property public String errorMessage; + property public String signalName; + } + + public static final class SessionExit.Status implements org.connectbot.sshlib.SessionExit { + ctor public SessionExit.Status(long code); + method public long component1(); + method public org.connectbot.sshlib.SessionExit.Status copy(optional long code); + method public boolean equals(java.lang.Object? other); + method @InaccessibleFromKotlin public long getCode(); + method public int hashCode(); + method public java.lang.String toString(); + property public long code; + } + public final class SftpAttributes { ctor public SftpAttributes(); ctor public SftpAttributes(optional java.lang.Long? size, optional java.lang.Integer? uid, optional java.lang.Integer? gid, optional java.lang.Integer? permissions, optional java.lang.Integer? atime, optional java.lang.Integer? mtime); @@ -674,6 +705,7 @@ package org.connectbot.sshlib { public interface SshSession { method public void close(); + method @InaccessibleFromKotlin public kotlinx.coroutines.Deferred getExitInfo(); method @InaccessibleFromKotlin public int getLocalChannelNumber(); method @InaccessibleFromKotlin public int getRemoteChannelNumber(); method @InaccessibleFromKotlin public kotlinx.coroutines.channels.ReceiveChannel getStderr(); @@ -688,6 +720,7 @@ package org.connectbot.sshlib { method public suspend java.lang.Object? resizeTerminal(int widthChars, int heightRows, int widthPixels, int heightPixels, kotlin.coroutines.Continuation); method public suspend java.lang.Object? sendEof(kotlin.coroutines.Continuation); method public suspend java.lang.Object? write(byte[] data, kotlin.coroutines.Continuation); + property public abstract kotlinx.coroutines.Deferred exitInfo; property public abstract boolean isOpen; property public abstract int localChannelNumber; property public abstract int remoteChannelNumber; @@ -761,6 +794,7 @@ package org.connectbot.sshlib.client { public final class SessionChannel implements org.connectbot.sshlib.SshSession { method public void close(); + method @InaccessibleFromKotlin public kotlinx.coroutines.Deferred getExitInfo(); method @InaccessibleFromKotlin public int getLocalChannelNumber(); method @InaccessibleFromKotlin public int getRemoteChannelNumber(); method @InaccessibleFromKotlin public kotlinx.coroutines.channels.ReceiveChannel getStderr(); @@ -775,6 +809,7 @@ package org.connectbot.sshlib.client { method public suspend java.lang.Object? resizeTerminal(int widthChars, int heightRows, int widthPixels, int heightPixels, kotlin.coroutines.Continuation); method public suspend java.lang.Object? sendEof(kotlin.coroutines.Continuation); method public suspend java.lang.Object? write(byte[] data, kotlin.coroutines.Continuation); + property public kotlinx.coroutines.Deferred exitInfo; property public boolean isOpen; property public int localChannelNumber; property public int remoteChannelNumber; diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/SshSession.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/SshSession.kt index ef34d15..99442b2 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/SshSession.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/SshSession.kt @@ -17,8 +17,31 @@ package org.connectbot.sshlib +import kotlinx.coroutines.Deferred import kotlinx.coroutines.channels.ReceiveChannel +/** + * How the remote process on a session channel terminated + * (RFC 4254 section 6.10). + */ +sealed interface SessionExit { + /** The remote process exited normally with [code] (`exit-status`). */ + data class Status(val code: Long) : SessionExit + + /** + * The remote process was terminated by a signal (`exit-signal`). + * + * @param signalName Signal name without the "SIG" prefix (e.g. "KILL") + * @param coreDumped Whether a core dump was produced + * @param errorMessage Additional textual explanation from the server; may be empty + */ + data class Signal( + val signalName: String, + val coreDumped: Boolean, + val errorMessage: String, + ) : SessionExit +} + /** * Represents an SSH session channel (RFC 4254 section 6). * @@ -86,5 +109,16 @@ interface SshSession : AutoCloseable { suspend fun sendEof() + /** + * Completes with how the remote process terminated once the server + * reports it (RFC 4254 section 6.10), or with null when the channel + * closes without an `exit-status`/`exit-signal` notification. Servers + * are not required to send one, so null means unknown, not failure. + * + * Typically awaited after [stdout] reaches end-of-stream on an + * exec channel to collect the command's exit code. + */ + val exitInfo: Deferred + override fun close() } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SessionChannel.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SessionChannel.kt index 581bbdb..bdd01cf 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SessionChannel.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SessionChannel.kt @@ -20,6 +20,7 @@ package org.connectbot.sshlib.client import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ReceiveChannel @@ -27,6 +28,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import org.connectbot.sshlib.SessionExit import org.connectbot.sshlib.SshSession import org.connectbot.sshlib.protocol.ByteString import org.connectbot.sshlib.protocol.ChannelRequestExec @@ -84,6 +86,18 @@ class SessionChannel internal constructor( override val stdout: ReceiveChannel get() = _stdout override val stderr: ReceiveChannel get() = _stderr + private val _exitInfo = CompletableDeferred() + override val exitInfo: Deferred get() = _exitInfo + + /** + * Called from the connection's packet loop when the server reports how + * the remote process terminated (RFC 4254 section 6.10). First report + * wins; the RFC allows at most one of exit-status/exit-signal. + */ + internal fun receiveExitInfo(info: SessionExit) { + _exitInfo.complete(info) + } + private var ptyGranted = false private var obfuscator: KeystrokeObfuscator? = null private val obfuscatorMutex = Mutex() @@ -198,6 +212,9 @@ class SessionChannel internal constructor( _stderr.close() _extendedData.close() windowAvailable.close() + // Channel is gone; if the server never reported an exit, resolve + // waiters with "unknown" rather than leaving them suspended. + _exitInfo.complete(null) } internal suspend fun onDisconnected() { @@ -445,6 +462,7 @@ class SessionChannel internal constructor( _stdout.close() _stderr.close() _extendedData.close() + _exitInfo.complete(null) try { connection.sendChannelClose(_remoteChannelNumber) } catch (e: Exception) { diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt index 0ce5f11..a25f0ad 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt @@ -54,6 +54,7 @@ import org.connectbot.sshlib.HostKeyVerifier import org.connectbot.sshlib.KeyboardInteractiveCallback import org.connectbot.sshlib.PingResult import org.connectbot.sshlib.PublicKey +import org.connectbot.sshlib.SessionExit import org.connectbot.sshlib.SshException import org.connectbot.sshlib.crypto.CipherEntry import org.connectbot.sshlib.crypto.CompressionEntry @@ -77,6 +78,8 @@ import org.connectbot.sshlib.protocol.AsciiString import org.connectbot.sshlib.protocol.ChannelOpenDirectTcpip import org.connectbot.sshlib.protocol.ChannelOpenForwardedTcpip import org.connectbot.sshlib.protocol.ChannelOpenSession +import org.connectbot.sshlib.protocol.ChannelRequestExitSignal +import org.connectbot.sshlib.protocol.ChannelRequestExitStatus import org.connectbot.sshlib.protocol.GlobalRequestCancelTcpipForward import org.connectbot.sshlib.protocol.GlobalRequestResponseTcpipForward import org.connectbot.sshlib.protocol.GlobalRequestTcpipForward @@ -2582,9 +2585,31 @@ class SshConnection( logger.debug("Received channel request: ${msg.requestType().value()} (want_reply=${msg.wantReply() != 0})") } val requestAccepted = when (val entry = channelRegistry.findByLocalRecipient(recipientChannel)) { - is SshChannelRegistry.Entry.Established.Session -> entry.channel.receiveRequest(deliverRequest) + is SshChannelRegistry.Entry.Established.Session -> entry.channel.receiveRequest { + deliverRequest() + // RFC 4254 section 6.10: surface how the remote + // process terminated to exitInfo waiters. + when (val fields = msg.requestSpecificFields()) { + is ChannelRequestExitStatus -> entry.channel.receiveExitInfo( + SessionExit.Status(fields.exitStatus()), + ) + + is ChannelRequestExitSignal -> entry.channel.receiveExitInfo( + SessionExit.Signal( + signalName = fields.signalName().data().decodeToString(), + coreDumped = fields.coreDumped() != 0, + errorMessage = fields.errorMessage().value(), + ), + ) + + else -> Unit + } + } + is SshChannelRegistry.Entry.Established.Agent -> entry.channel.receiveRequest(deliverRequest) + is SshChannelRegistry.Entry.Established.Forwarding -> entry.channel.receiveRequest(deliverRequest) + null -> throw ProtocolViolationException("Channel request for unknown channel $recipientChannel") } if (!requestAccepted) { diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/FakeSshServer.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/FakeSshServer.kt index 53ea115..54993f5 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/FakeSshServer.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/FakeSshServer.kt @@ -35,6 +35,8 @@ import org.connectbot.sshlib.crypto.MacEntry import org.connectbot.sshlib.crypto.SshPublicKeyEncoder import org.connectbot.sshlib.crypto.X25519ProviderFactory import org.connectbot.sshlib.crypto.encodeMpint +import org.connectbot.sshlib.protocol.ChannelRequestExitSignal +import org.connectbot.sshlib.protocol.ChannelRequestExitStatus import org.connectbot.sshlib.protocol.SshEnums import org.connectbot.sshlib.protocol.SshMsgChannelData import org.connectbot.sshlib.protocol.SshMsgChannelFailure @@ -807,6 +809,50 @@ class FakeSshServer( } } + /** SSH_MSG_CHANNEL_REQUEST `exit-status` (RFC 4254 section 6.10). */ + suspend fun sendChannelExitStatus(recipientChannel: Int, exitStatus: Long) { + val statusReq = ChannelRequestExitStatus().apply { + setExitStatus(exitStatus) + _check() + } + val msg = SshMsgChannelRequest().apply { + setRecipientChannel(recipientChannel.toLong()) + setRequestType(createAsciiString("exit-status")) + setWantReply(0) + setRequestSpecificFields(statusReq) + _check() + } + writeMutex.withLock { + serverIo.writePacket(SshEnums.MessageType.SSH_MSG_CHANNEL_REQUEST.id().toInt(), msg.toByteArray()) + } + } + + /** SSH_MSG_CHANNEL_REQUEST `exit-signal` (RFC 4254 section 6.10). */ + suspend fun sendChannelExitSignal( + recipientChannel: Int, + signalName: String, + coreDumped: Boolean = false, + errorMessage: String = "", + ) { + val signalReq = ChannelRequestExitSignal().apply { + setSignalName(createByteString(signalName.toByteArray(Charsets.US_ASCII))) + setCoreDumped(if (coreDumped) 1 else 0) + setErrorMessage(createUtf8String(errorMessage)) + setLanguageTag(createByteString(ByteArray(0))) + _check() + } + val msg = SshMsgChannelRequest().apply { + setRecipientChannel(recipientChannel.toLong()) + setRequestType(createAsciiString("exit-signal")) + setWantReply(0) + setRequestSpecificFields(signalReq) + _check() + } + writeMutex.withLock { + serverIo.writePacket(SshEnums.MessageType.SSH_MSG_CHANNEL_REQUEST.id().toInt(), msg.toByteArray()) + } + } + suspend fun sendChannelEof(recipientChannel: Int) { val payload = ByteBuffer.allocate(4).putInt(recipientChannel).array() writeMutex.withLock { diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshClientIntegrationTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshClientIntegrationTest.kt index 4d41c93..9e2a76a 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshClientIntegrationTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshClientIntegrationTest.kt @@ -31,6 +31,7 @@ import org.connectbot.sshlib.HostKeyVerifier import org.connectbot.sshlib.KeyboardInteractiveCallback import org.connectbot.sshlib.PingResult import org.connectbot.sshlib.PublicKey +import org.connectbot.sshlib.SessionExit import org.connectbot.sshlib.SshClient import org.connectbot.sshlib.SshClientConfig import org.connectbot.sshlib.SshException @@ -863,6 +864,78 @@ class SshClientIntegrationTest { } } + @Test + fun `should surface exec exit status`() = runBlocking { + val host = opensshContainer.host + val port = opensshContainer.getMappedPort(22) + + val config = SshClientConfig { + this.host = host + this.port = port + hostKeyVerifier = acceptAllVerifier + } + val client = SshClient(config) + try { + assertTrue(client.connect() is ConnectResult.Success, "Should connect") + assertTrue(client.authenticatePassword(USERNAME, PASSWORD) is AuthResult.Success, "Should authenticate") + + val session = client.openSession() + assertNotNull(session) + + assertTrue(session!!.requestExec("sh -c 'exit 42'"), "Should successfully request exec") + + // Drain stdout to EOF first — the exit report follows the data. + withTimeout(5_000) { + while (session.read() != null) { + // discard + } + } + val exit = withTimeout(5_000) { session.exitInfo.await() } + assertEquals(SessionExit.Status(42L), exit) + + session.close() + } finally { + client.disconnect() + } + } + + @Test + fun `should surface exec exit signal`() = runBlocking { + val host = opensshContainer.host + val port = opensshContainer.getMappedPort(22) + + val config = SshClientConfig { + this.host = host + this.port = port + hostKeyVerifier = acceptAllVerifier + } + val client = SshClient(config) + try { + assertTrue(client.connect() is ConnectResult.Success, "Should connect") + assertTrue(client.authenticatePassword(USERNAME, PASSWORD) is AuthResult.Success, "Should authenticate") + + val session = client.openSession() + assertNotNull(session) + + assertTrue(session!!.requestExec("sh -c 'kill -KILL \$\$'"), "Should successfully request exec") + + withTimeout(5_000) { + while (session.read() != null) { + // discard + } + } + val exit = withTimeout(5_000) { session.exitInfo.await() } + assertTrue( + exit is SessionExit.Signal && exit.signalName == "KILL", + "Expected KILL exit-signal, got: $exit", + ) + + session.close() + } finally { + client.disconnect() + } + } + @Test fun `rekey occurs when byte limit is exceeded`() = runBlocking { val host = opensshContainer.host diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshConnectionFlowTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshConnectionFlowTest.kt index 00c22f9..d120a9f 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshConnectionFlowTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshConnectionFlowTest.kt @@ -42,6 +42,7 @@ import org.connectbot.sshlib.DestinationConstraint import org.connectbot.sshlib.HostKeyVerifier import org.connectbot.sshlib.KeyboardInteractiveCallback import org.connectbot.sshlib.PublicKey +import org.connectbot.sshlib.SessionExit import org.connectbot.sshlib.crypto.PrivateKeyReader import org.connectbot.sshlib.crypto.SshPublicKeyEncoder import org.connectbot.sshlib.transport.PipedTransport @@ -451,6 +452,81 @@ class SshConnectionFlowTest { } } + @Test + fun `exit-status channel request completes exitInfo`() = runTest { + connectedFixture { connection, server, dispatcher -> + authenticate(connection, server, dispatcher) + val session = openSession(connection, server, dispatcher) + val localChannel = session.localChannelNumber + + server.sendChannelExitStatus(localChannel, 42L) + assertEquals( + SessionExit.Status(42L), + withTimeout(5_000) { session.exitInfo.await() }, + ) + } + } + + @Test + fun `exit-status supports max uint32 boundary condition`() = runTest { + connectedFixture { connection, server, dispatcher -> + authenticate(connection, server, dispatcher) + val session = openSession(connection, server, dispatcher) + val localChannel = session.localChannelNumber + + server.sendChannelExitStatus(localChannel, 0xFFFF_FFFFL) + assertEquals( + SessionExit.Status(0xFFFF_FFFFL), + withTimeout(5_000) { session.exitInfo.await() }, + ) + } + } + + @Test + fun `exit-signal channel request completes exitInfo`() = runTest { + connectedFixture { connection, server, dispatcher -> + authenticate(connection, server, dispatcher) + val session = openSession(connection, server, dispatcher) + val localChannel = session.localChannelNumber + + server.sendChannelExitSignal(localChannel, "KILL", coreDumped = true, errorMessage = "killed") + assertEquals( + SessionExit.Signal("KILL", coreDumped = true, errorMessage = "killed"), + withTimeout(5_000) { session.exitInfo.await() }, + ) + } + } + + @Test + fun `exitInfo resolves null when the channel closes without an exit report`() = runTest { + connectedFixture { connection, server, dispatcher -> + authenticate(connection, server, dispatcher) + val session = openSession(connection, server, dispatcher) + val localChannel = session.localChannelNumber + + server.sendChannelEof(localChannel) + server.sendChannelClose(localChannel) + assertNull(withTimeout(5_000) { session.exitInfo.await() }) + } + } + + @Test + fun `first exit report wins over a later one`() = runTest { + connectedFixture { connection, server, dispatcher -> + authenticate(connection, server, dispatcher) + val session = openSession(connection, server, dispatcher) + val localChannel = session.localChannelNumber + + server.sendChannelExitStatus(localChannel, 7L) + server.sendChannelExitSignal(localChannel, "TERM") + server.sendChannelClose(localChannel) + assertEquals( + SessionExit.Status(7L), + withTimeout(5_000) { session.exitInfo.await() }, + ) + } + } + @Test fun `direct tcpip channel routes data eof and close`() = runTest { connectedFixture { connection, server, dispatcher -> diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpClientImplTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpClientImplTest.kt index 9621c75..5939def 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpClientImplTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpClientImplTest.kt @@ -17,11 +17,14 @@ package org.connectbot.sshlib.client.sftp +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import org.connectbot.sshlib.SessionExit import org.connectbot.sshlib.SftpAttributes import org.connectbot.sshlib.SftpClient import org.connectbot.sshlib.SftpDirectoryEntry @@ -441,6 +444,8 @@ class SftpClientImplTest { override val isOpen: Boolean get() = open override val stdout: ReceiveChannel = Channel() override val stderr: ReceiveChannel = Channel() + override val exitInfo: Deferred = + CompletableDeferred(null) fun enqueueRead(data: ByteArray) { reads.trySend(data).getOrThrow() diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpPacketIOTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpPacketIOTest.kt index a7382d3..a8001ec 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpPacketIOTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpPacketIOTest.kt @@ -17,9 +17,12 @@ package org.connectbot.sshlib.client.sftp +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.runBlocking +import org.connectbot.sshlib.SessionExit import org.connectbot.sshlib.SftpResult import org.connectbot.sshlib.SshSession import org.junit.jupiter.api.Test @@ -97,6 +100,8 @@ class SftpPacketIOTest { override val isOpen: Boolean = true override val stdout: ReceiveChannel = Channel() override val stderr: ReceiveChannel = Channel() + override val exitInfo: Deferred = + CompletableDeferred(null) fun enqueue(data: ByteArray) { reads.trySend(data).getOrThrow()