Skip to content
Merged
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
5 changes: 5 additions & 0 deletions sshlib/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,7 @@ package org.connectbot.sshlib {
}

public final class SshClientConfig {
method @InaccessibleFromKotlin public boolean getAutoDisconnectOnLastChannelClose();
method @InaccessibleFromKotlin public java.lang.String getClientVersion();
method @InaccessibleFromKotlin public java.lang.String getEncryptionAlgorithms();
method @InaccessibleFromKotlin public java.lang.String getHostKeyAlgorithms();
Expand All @@ -623,6 +624,7 @@ package org.connectbot.sshlib {
method @InaccessibleFromKotlin public long getRekeyBytesLimit();
method @InaccessibleFromKotlin public long getRekeyIntervalMs();
method @InaccessibleFromKotlin public org.connectbot.sshlib.transport.TransportFactory getTransportFactory();
property public boolean autoDisconnectOnLastChannelClose;
property public String clientVersion;
property public String encryptionAlgorithms;
property public String hostKeyAlgorithms;
Expand All @@ -640,6 +642,7 @@ package org.connectbot.sshlib {
public static final class SshClientConfig.Builder {
ctor public SshClientConfig.Builder();
method public org.connectbot.sshlib.SshClientConfig build();
method @InaccessibleFromKotlin public boolean getAutoDisconnectOnLastChannelClose();
method @InaccessibleFromKotlin public java.lang.String getClientVersion();
method @InaccessibleFromKotlin public boolean getEnableCompression();
method @InaccessibleFromKotlin public java.lang.String getEncryptionAlgorithms();
Expand All @@ -655,6 +658,7 @@ package org.connectbot.sshlib {
method @InaccessibleFromKotlin public long getRekeyBytesLimit();
method @InaccessibleFromKotlin public long getRekeyIntervalMs();
method @InaccessibleFromKotlin public org.connectbot.sshlib.transport.TransportFactory? getTransportFactory();
method @InaccessibleFromKotlin public void setAutoDisconnectOnLastChannelClose(boolean);
method @InaccessibleFromKotlin public void setClientVersion(java.lang.String);
method @InaccessibleFromKotlin public void setEnableCompression(boolean);
method @InaccessibleFromKotlin public void setEncryptionAlgorithms(java.lang.String);
Expand All @@ -670,6 +674,7 @@ package org.connectbot.sshlib {
method @InaccessibleFromKotlin public void setRekeyBytesLimit(long);
method @InaccessibleFromKotlin public void setRekeyIntervalMs(long);
method @InaccessibleFromKotlin public void setTransportFactory(org.connectbot.sshlib.transport.TransportFactory?);
property public boolean autoDisconnectOnLastChannelClose;
property public String clientVersion;
property public boolean enableCompression;
property public String encryptionAlgorithms;
Expand Down
1 change: 1 addition & 0 deletions sshlib/src/main/kotlin/org/connectbot/sshlib/SshClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ class SshClient private constructor(
rekeyBytesLimit = config.rekeyBytesLimit,
obscureKeystrokeTimingIntervalMs = config.obscureKeystrokeTimingIntervalMs,
)
sshConnection.autoDisconnectOnLastChannelClose = config.autoDisconnectOnLastChannelClose
val result = sshConnection.connect()

if (result is ConnectResult.Success) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/*
* ConnectBot SSH Library
* Copyright 2025 Kenny Root
* Copyright 2025-2026 Kenny Root
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -58,6 +58,7 @@ class SshClientConfig private constructor(
val rekeyIntervalMs: Long,
val rekeyBytesLimit: Long,
val obscureKeystrokeTimingIntervalMs: Long,
val autoDisconnectOnLastChannelClose: Boolean,
) {
class Builder {
/**
Expand Down Expand Up @@ -128,6 +129,11 @@ class SshClientConfig private constructor(
*/
var obscureKeystrokeTimingIntervalMs: Long = 20L

/**
* Whether to automatically disconnect the SSH connection when the last channel is closed.
*/
var autoDisconnectOnLastChannelClose: Boolean = true

fun build(): SshClientConfig {
val factory = transportFactory ?: run {
require(host.isNotBlank()) { "Host must be specified when using default TCP transport" }
Expand Down Expand Up @@ -159,6 +165,7 @@ class SshClientConfig private constructor(
rekeyIntervalMs,
rekeyBytesLimit,
obscureKeystrokeTimingIntervalMs,
autoDisconnectOnLastChannelClose,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,20 @@ internal class AgentChannel(
requests.close()
requestWorker.cancelAndJoin()
windowAvailable.close()
if (SshChannelEffect.CLOSE_CHANNEL in transition.effects) {
connection.notifyChannelClosed(localChannelNumber)
}
}
}

suspend fun onDisconnected() {
lifecycle.disconnect {
lifecycle.disconnect { transition ->
requests.close()
requestWorker.cancelAndJoin()
windowAvailable.close()
if (SshChannelEffect.CLOSE_CHANNEL in transition.effects) {
connection.notifyChannelClosed(localChannelNumber)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,18 +111,24 @@ internal class ForwardingChannel(
incomingDeliveryJob.cancel()
_incomingData.close()
windowAvailable.close()
if (SshChannelEffect.CLOSE_CHANNEL in transition.effects) {
connection.notifyChannelClosed(localChannelNumber)
}
}
) {
throw SshException("Received duplicate CLOSE on forwarding channel $localChannelNumber")
logger.debug("Received duplicate CLOSE on forwarding channel $localChannelNumber")
}
}

internal suspend fun onDisconnected() {
lifecycle.disconnect {
lifecycle.disconnect { transition ->
incomingIngress.close()
incomingDeliveryJob.cancel()
_incomingData.close()
windowAvailable.close()
if (SshChannelEffect.CLOSE_CHANNEL in transition.effects) {
connection.notifyChannelClosed(localChannelNumber)
}
}
}

Expand Down Expand Up @@ -152,11 +158,14 @@ internal class ForwardingChannel(
}

suspend fun close() {
lifecycle.sendClose {
lifecycle.sendClose { transition ->
incomingIngress.close()
incomingDeliveryJob.cancel()
_incomingData.close()
connection.sendChannelClose(remoteChannelNumber)
if (SshChannelEffect.CLOSE_CHANNEL in transition.effects) {
connection.notifyChannelClosed(localChannelNumber)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,12 @@ class SessionChannel internal constructor(
internal suspend fun onClose() {
if (!lifecycle.receiveClose { transition ->
closeResources(SshChannelEffect.SEND_CLOSE in transition.effects, "Received CLOSE")
if (SshChannelEffect.CLOSE_CHANNEL in transition.effects) {
connection.notifyChannelClosed(localChannelNumber)
}
}
) {
throw org.connectbot.sshlib.SshException("Received duplicate CLOSE on channel $localChannelNumber")
logger.debug("Received duplicate CLOSE on channel $localChannelNumber")
}
}

Expand Down Expand Up @@ -218,7 +221,12 @@ class SessionChannel internal constructor(
}

internal suspend fun onDisconnected() {
lifecycle.disconnect { closeResources(replyRequired = false, reason = "Disconnected") }
lifecycle.disconnect { transition ->
closeResources(replyRequired = false, reason = "Disconnected")
if (SshChannelEffect.CLOSE_CHANNEL in transition.effects) {
connection.notifyChannelClosed(localChannelNumber)
}
}
}

internal suspend fun receiveRequest(action: suspend () -> Unit): Boolean = lifecycle.receiveRequest { action() }
Expand Down Expand Up @@ -449,7 +457,7 @@ class SessionChannel internal constructor(

override fun close() {
connectionScope.launch(start = CoroutineStart.UNDISPATCHED) {
lifecycle.sendClose {
lifecycle.sendClose { transition ->
logger.debug("Closing channel $localChannelNumber")
obfuscatorMutex.withLock { obfuscator?.stop() }
chaffJob?.cancel()
Expand All @@ -468,6 +476,9 @@ class SessionChannel internal constructor(
} catch (e: Exception) {
logger.debug("Failed to send CHANNEL_CLOSE", e)
}
if (SshChannelEffect.CLOSE_CHANNEL in transition.effects) {
connection.notifyChannelClosed(localChannelNumber)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ class SshConnection(
private val obscureKeystrokeTimingIntervalMs: Long = 20L,
coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
internal var autoDisconnectOnLastChannelClose: Boolean = true

companion object {
private val logger = LoggerFactory.getLogger(SshConnection::class.java)
Expand Down Expand Up @@ -2561,17 +2562,10 @@ class SshConnection(
logger.debug("Received CHANNEL_CLOSE for local channel $recipientChannel")
when (val entry = channelRegistry.findByLocalRecipient(recipientChannel)) {
is SshChannelRegistry.Entry.Established.Session -> entry.channel.onClose()

is SshChannelRegistry.Entry.Established.Agent -> entry.channel.onClose()

is SshChannelRegistry.Entry.Established.Forwarding -> {
entry.channel.onClose()
unregisterForwardingChannel(entry.channel)
}

is SshChannelRegistry.Entry.Established.Forwarding -> entry.channel.onClose()
null -> throw ProtocolViolationException("Close for unknown channel $recipientChannel")
}
checkAllChannelsClosed()
}

SshEnums.MessageType.SSH_MSG_CHANNEL_REQUEST -> {
Expand Down Expand Up @@ -2881,11 +2875,20 @@ class SshConnection(
)
}

private suspend fun checkAllChannelsClosed() {
val established = channelRegistry.establishedSnapshot()
val allClosed = established.all { !it.isOpen }
logger.debug("checkAllChannelsClosed: established=${established.size}, allClosed=$allClosed")
if (allClosed && established.any { it.kind == SshChannelRegistry.Kind.SESSION }) {
internal suspend fun notifyChannelClosed(localChannelNumber: Int) {
val entry = channelRegistry.findByLocalRecipient(localChannelNumber)
val wasSession = entry is SshChannelRegistry.Entry.Established.Session
channelRegistry.unregister(localChannelNumber)
if (entry is SshChannelRegistry.Entry.Established.Forwarding) {
unregisterForwardingChannel(entry.channel)
}

val snapshot = channelRegistry.snapshot()
val allClosed = snapshot.none { it is SshChannelRegistry.Entry.Pending } &&
snapshot.filterIsInstance<SshChannelRegistry.Entry.Established>().all { !it.isOpen }
val hasSession = snapshot.any { it.kind == SshChannelRegistry.Kind.SESSION }

if (autoDisconnectOnLastChannelClose && allClosed && (hasSession || wasSession)) {
logger.info("All channels closed, sending disconnect")
sendDisconnect()
}
Expand Down
Loading
Loading