diff --git a/sshlib/api.txt b/sshlib/api.txt index f2dbb36..79f6ae7 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(int code); + method public int component1(); + method public org.connectbot.sshlib.SessionExit.Status copy(optional int code); + method public boolean equals(java.lang.Object? other); + method @InaccessibleFromKotlin public int getCode(); + method public int hashCode(); + method public java.lang.String toString(); + property public int 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..67cae21 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: Int) : 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 62ebb4f..929877a 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 @@ -2563,9 +2566,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().toInt()), + ) + + is ChannelRequestExitSignal -> entry.channel.receiveExitInfo( + SessionExit.Signal( + signalName = fields.signalName().data().decodeToString(), + coreDumped = fields.coreDumped().toInt() != 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 3534a68..eab0147 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/FakeSshServer.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/FakeSshServer.kt @@ -779,6 +779,38 @@ class FakeSshServer( } } + /** SSH_MSG_CHANNEL_REQUEST `exit-status` (RFC 4254 section 6.10). */ + suspend fun sendChannelExitStatus(recipientChannel: Int, exitStatus: Int) { + val payload = ByteArrayOutputStream() + payload.write(ByteBuffer.allocate(4).putInt(recipientChannel).array()) + payload.writeString("exit-status".toByteArray(Charsets.US_ASCII)) + payload.write(0) // want_reply = false + payload.write(ByteBuffer.allocate(4).putInt(exitStatus).array()) + writeMutex.withLock { + serverIo.writePacket(SshEnums.MessageType.SSH_MSG_CHANNEL_REQUEST.id().toInt(), payload.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 payload = ByteArrayOutputStream() + payload.write(ByteBuffer.allocate(4).putInt(recipientChannel).array()) + payload.writeString("exit-signal".toByteArray(Charsets.US_ASCII)) + payload.write(0) // want_reply = false + payload.writeString(signalName.toByteArray(Charsets.US_ASCII)) + payload.write(if (coreDumped) 1 else 0) + payload.writeString(errorMessage.toByteArray(Charsets.UTF_8)) + payload.writeString(ByteArray(0)) // language tag + writeMutex.withLock { + serverIo.writePacket(SshEnums.MessageType.SSH_MSG_CHANNEL_REQUEST.id().toInt(), payload.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..c550588 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshClientIntegrationTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshClientIntegrationTest.kt @@ -863,6 +863,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(org.connectbot.sshlib.SessionExit.Status(42), 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 org.connectbot.sshlib.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 8943022..3a66da5 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshConnectionFlowTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshConnectionFlowTest.kt @@ -434,6 +434,66 @@ 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, 42) + assertEquals( + org.connectbot.sshlib.SessionExit.Status(42), + 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( + org.connectbot.sshlib.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, 7) + server.sendChannelExitSignal(localChannel, "TERM") + server.sendChannelClose(localChannel) + assertEquals( + org.connectbot.sshlib.SessionExit.Status(7), + 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 b9732e9..9bce115 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 @@ -422,6 +422,8 @@ class SftpClientImplTest { override val isOpen: Boolean get() = open override val stdout: ReceiveChannel = Channel() override val stderr: ReceiveChannel = Channel() + override val exitInfo: kotlinx.coroutines.Deferred = + kotlinx.coroutines.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..0e18e91 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 @@ -97,6 +97,8 @@ class SftpPacketIOTest { override val isOpen: Boolean = true override val stdout: ReceiveChannel = Channel() override val stderr: ReceiveChannel = Channel() + override val exitInfo: kotlinx.coroutines.Deferred = + kotlinx.coroutines.CompletableDeferred(null) fun enqueue(data: ByteArray) { reads.trySend(data).getOrThrow()