Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions sshlib/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -674,6 +705,7 @@ package org.connectbot.sshlib {

public interface SshSession {
method public void close();
method @InaccessibleFromKotlin public kotlinx.coroutines.Deferred<org.connectbot.sshlib.SessionExit?> getExitInfo();
method @InaccessibleFromKotlin public int getLocalChannelNumber();
method @InaccessibleFromKotlin public int getRemoteChannelNumber();
method @InaccessibleFromKotlin public kotlinx.coroutines.channels.ReceiveChannel<byte[]> getStderr();
Expand All @@ -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<? super java.lang.Boolean>);
method public suspend java.lang.Object? sendEof(kotlin.coroutines.Continuation<? super kotlin.Unit>);
method public suspend java.lang.Object? write(byte[] data, kotlin.coroutines.Continuation<? super kotlin.Unit>);
property public abstract kotlinx.coroutines.Deferred<org.connectbot.sshlib.SessionExit?> exitInfo;
property public abstract boolean isOpen;
property public abstract int localChannelNumber;
property public abstract int remoteChannelNumber;
Expand Down Expand Up @@ -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<org.connectbot.sshlib.SessionExit?> getExitInfo();
method @InaccessibleFromKotlin public int getLocalChannelNumber();
method @InaccessibleFromKotlin public int getRemoteChannelNumber();
method @InaccessibleFromKotlin public kotlinx.coroutines.channels.ReceiveChannel<byte[]> getStderr();
Expand All @@ -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<? super java.lang.Boolean>);
method public suspend java.lang.Object? sendEof(kotlin.coroutines.Continuation<? super kotlin.Unit>);
method public suspend java.lang.Object? write(byte[] data, kotlin.coroutines.Continuation<? super kotlin.Unit>);
property public kotlinx.coroutines.Deferred<org.connectbot.sshlib.SessionExit?> exitInfo;
property public boolean isOpen;
property public int localChannelNumber;
property public int remoteChannelNumber;
Expand Down
34 changes: 34 additions & 0 deletions sshlib/src/main/kotlin/org/connectbot/sshlib/SshSession.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand Down Expand Up @@ -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<SessionExit?>

override fun close()
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ 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
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
Expand Down Expand Up @@ -84,6 +86,18 @@ class SessionChannel internal constructor(
override val stdout: ReceiveChannel<ByteArray> get() = _stdout
override val stderr: ReceiveChannel<ByteArray> get() = _stderr

private val _exitInfo = CompletableDeferred<SessionExit?>()
override val exitInfo: Deferred<SessionExit?> 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()
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -445,6 +462,7 @@ class SessionChannel internal constructor(
_stdout.close()
_stderr.close()
_extendedData.close()
_exitInfo.complete(null)
try {
connection.sendChannelClose(_remoteChannelNumber)
} catch (e: Exception) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the RFC says this should be uint32, but this is converting to integer here. Prefer Long to preserve the full range. It might be prudent to add a test for the 0xffff_ffffL boundary condition as well.

)

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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be able to use the SshMsgChannelRequest, ChannelRequestExitStatus, and ChannelRequestExitSignal types since they already support writing.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be able to use the SshMsgChannelRequest, ChannelRequestExitStatus, and ChannelRequestExitSignal types since they already support writing.

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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading