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
8 changes: 8 additions & 0 deletions sshlib/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -433,8 +433,10 @@ package org.connectbot.sshlib {
public interface SftpClient {
method public void close();
method public suspend java.lang.Object? close(org.connectbot.sshlib.SftpFileHandle handle, kotlin.coroutines.Continuation<? super org.connectbot.sshlib.SftpResult<kotlin.Unit>>);
method public suspend java.lang.Object? copyData(org.connectbot.sshlib.SftpFileHandle srcHandle, long srcOffset, long length, org.connectbot.sshlib.SftpFileHandle dstHandle, long dstOffset, kotlin.coroutines.Continuation<? super org.connectbot.sshlib.SftpResult<kotlin.Unit>>);
method public suspend java.lang.Object? fsetstat(org.connectbot.sshlib.SftpFileHandle handle, org.connectbot.sshlib.SftpAttributes attrs, kotlin.coroutines.Continuation<? super org.connectbot.sshlib.SftpResult<kotlin.Unit>>);
method public suspend java.lang.Object? fstat(org.connectbot.sshlib.SftpFileHandle handle, kotlin.coroutines.Continuation<? super org.connectbot.sshlib.SftpResult<org.connectbot.sshlib.SftpAttributes>>);
method @InaccessibleFromKotlin public java.util.Set<java.lang.String> getExtensions();
method @InaccessibleFromKotlin public int getProtocolVersion();
method @InaccessibleFromKotlin public boolean isOpen();
method public default suspend java.lang.Object? listdir(java.lang.String path, kotlin.coroutines.Continuation<? super org.connectbot.sshlib.SftpResult<? extends java.util.List<org.connectbot.sshlib.SftpDirectoryEntry>>>);
Expand All @@ -453,6 +455,7 @@ package org.connectbot.sshlib {
method public suspend java.lang.Object? stat(java.lang.String path, kotlin.coroutines.Continuation<? super org.connectbot.sshlib.SftpResult<org.connectbot.sshlib.SftpAttributes>>);
method public suspend java.lang.Object? symlink(java.lang.String targetPath, java.lang.String linkPath, kotlin.coroutines.Continuation<? super org.connectbot.sshlib.SftpResult<kotlin.Unit>>);
method public suspend java.lang.Object? write(org.connectbot.sshlib.SftpFileHandle handle, long offset, byte[] data, kotlin.coroutines.Continuation<? super org.connectbot.sshlib.SftpResult<kotlin.Unit>>);
property public abstract java.util.Set<java.lang.String> extensions;
property public abstract boolean isOpen;
property public abstract int protocolVersion;
}
Expand Down Expand Up @@ -617,6 +620,7 @@ package org.connectbot.sshlib {
method @InaccessibleFromKotlin public java.lang.String getEncryptionAlgorithms();
method @InaccessibleFromKotlin public java.lang.String getHostKeyAlgorithms();
method @InaccessibleFromKotlin public org.connectbot.sshlib.HostKeyVerifier getHostKeyVerifier();
method @InaccessibleFromKotlin public long getKeepAliveIntervalMs();
method @InaccessibleFromKotlin public java.lang.String getKexAlgorithms();
method @InaccessibleFromKotlin public java.lang.String getMacAlgorithms();
method @InaccessibleFromKotlin public long getObscureKeystrokeTimingIntervalMs();
Expand All @@ -629,6 +633,7 @@ package org.connectbot.sshlib {
property public String encryptionAlgorithms;
property public String hostKeyAlgorithms;
property public org.connectbot.sshlib.HostKeyVerifier hostKeyVerifier;
property public long keepAliveIntervalMs;
property public String kexAlgorithms;
property public String macAlgorithms;
property public long obscureKeystrokeTimingIntervalMs;
Expand All @@ -650,6 +655,7 @@ package org.connectbot.sshlib {
method @InaccessibleFromKotlin public java.lang.String getHostKeyAlgorithms();
method @InaccessibleFromKotlin public org.connectbot.sshlib.HostKeyVerifier? getHostKeyVerifier();
method @InaccessibleFromKotlin public org.connectbot.sshlib.transport.IpVersion getIpVersion();
method @InaccessibleFromKotlin public long getKeepAliveIntervalMs();
method @InaccessibleFromKotlin public java.lang.String getKexAlgorithms();
method @InaccessibleFromKotlin public java.lang.String getMacAlgorithms();
method @InaccessibleFromKotlin public long getObscureKeystrokeTimingIntervalMs();
Expand All @@ -666,6 +672,7 @@ package org.connectbot.sshlib {
method @InaccessibleFromKotlin public void setHostKeyAlgorithms(java.lang.String);
method @InaccessibleFromKotlin public void setHostKeyVerifier(org.connectbot.sshlib.HostKeyVerifier?);
method @InaccessibleFromKotlin public void setIpVersion(org.connectbot.sshlib.transport.IpVersion);
method @InaccessibleFromKotlin public void setKeepAliveIntervalMs(long);
method @InaccessibleFromKotlin public void setKexAlgorithms(java.lang.String);
method @InaccessibleFromKotlin public void setMacAlgorithms(java.lang.String);
method @InaccessibleFromKotlin public void setObscureKeystrokeTimingIntervalMs(long);
Expand All @@ -682,6 +689,7 @@ package org.connectbot.sshlib {
property public String hostKeyAlgorithms;
property public org.connectbot.sshlib.HostKeyVerifier? hostKeyVerifier;
property public org.connectbot.sshlib.transport.IpVersion ipVersion;
property public long keepAliveIntervalMs;
property public String kexAlgorithms;
property public String macAlgorithms;
property public long obscureKeystrokeTimingIntervalMs;
Expand Down
45 changes: 44 additions & 1 deletion sshlib/src/main/kotlin/org/connectbot/sshlib/SftpClient.kt
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 @@ -46,6 +46,18 @@ interface SftpClient : AutoCloseable {
/** The negotiated SFTP protocol version (typically 3). */
val protocolVersion: Int

/**
* SFTP protocol extensions the server advertised as `extension-name`/`extension-data`
* pairs trailing its `SSH_FXP_VERSION` reply (draft-ietf-secsh-filexfer-02 section 3).
* Only the names are kept; extension-specific data (if any) is discarded.
*
* Common OpenSSH extensions found here: `"copy-data"` (see [copyData]),
* `"posix-rename@openssh.com"`, `"hardlink@openssh.com"`, `"fsync@openssh.com"`,
* `"statvfs@openssh.com"`. Empty if the server advertised none (or a server this old
* predates extensions entirely).
*/
val extensions: Set<String>

/** Whether this SFTP session is still open. */
val isOpen: Boolean

Expand Down Expand Up @@ -146,6 +158,37 @@ interface SftpClient : AutoCloseable {
/** Rename or move a file. */
suspend fun rename(oldPath: String, newPath: String): SftpResult<Unit>

// --- Server-side data copy (OpenSSH extension) ---

/**
* Copies [length] bytes from [srcHandle] at [srcOffset] into [dstHandle] at [dstOffset],
* entirely on the server — no data crosses the wire. This is the `"copy-data"` SFTP
* protocol extension OpenSSH added in 9.0 (April 2022); it lets the server use an
* efficient server-side copy (e.g. `copy_file_range()` on Linux) instead of the client
* reading the whole file and writing it back, and works even for accounts restricted to
* `internal-sftp` with no shell access (where server-side `cp` via SSH exec cannot run
* at all).
*
* Both handles must already be open ([open] with [SftpOpenFlag.READ] for [srcHandle],
* [SftpOpenFlag.WRITE] for [dstHandle]) — this call does not open, create, or close
* anything. Only regular files are supported; there is no protocol-level operation for
* copying whole directory trees, so recursive copies still need to be driven by the
* caller (walk the tree, `mkdir` each directory, `copyData` each regular file).
*
* Check [extensions] for `"copy-data"` before calling, or be prepared to fall back on an
* [SftpResult.ServerError] with [SftpStatusCode.OP_UNSUPPORTED] — older or non-OpenSSH
* servers may not implement this extension at all.
*
* @param length Number of bytes to copy; `0` means "copy through EOF of the source file".
*/
suspend fun copyData(
srcHandle: SftpFileHandle,
srcOffset: Long,
length: Long,
dstHandle: SftpFileHandle,
dstOffset: Long,
): SftpResult<Unit>

// --- Path operations ---

/** Resolve a path to its canonical absolute form. */
Expand Down
35 changes: 35 additions & 0 deletions sshlib/src/main/kotlin/org/connectbot/sshlib/SshClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.connectbot.sshlib.PingResult
import org.connectbot.sshlib.client.DynamicPortForwarder
Expand Down Expand Up @@ -146,6 +148,8 @@ class SshClient private constructor(
private var authenticated = initialAuthenticated
private val forwardingScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var disconnectForwardJob: Job? = null
private var keepAliveJob: Job? = null
private val keepAliveScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

private val _disconnectedFlow = MutableSharedFlow<Throwable?>(extraBufferCapacity = 1)

Expand Down Expand Up @@ -227,6 +231,7 @@ class SshClient private constructor(
val result = conn.authenticatePassword(username, password)

if (result is AuthResult.Success) {
startKeepAlive()
authenticated = true
logger.info("Authentication successful")
} else {
Expand Down Expand Up @@ -263,6 +268,7 @@ class SshClient private constructor(
val result = conn.authenticateKeyboardInteractive(username, callback)

if (result is AuthResult.Success) {
startKeepAlive()
authenticated = true
logger.info("Keyboard-interactive authentication successful")
} else {
Expand Down Expand Up @@ -317,6 +323,7 @@ class SshClient private constructor(
val result = conn.authenticatePublicKey(username, privateKey)

if (result is AuthResult.Success) {
startKeepAlive()
authenticated = true
logger.info("Public key authentication successful")
} else {
Expand Down Expand Up @@ -354,6 +361,7 @@ class SshClient private constructor(
val result = conn.authenticate(username, handler)

if (result is AuthResult.Success) {
startKeepAlive()
authenticated = true
logger.info("Auth handler authentication successful")
} else {
Expand Down Expand Up @@ -694,6 +702,30 @@ class SshClient private constructor(
return conn.ping()
}

/**
* Start sending SSH_MSG_IGNORE heartbeats periodically.
* Called internally after successful authentication.
* No-op if keepAliveIntervalMs is 0 or keepalive already running.
*/
private fun startKeepAlive() {
val intervalMs = config.keepAliveIntervalMs
if (intervalMs <= 0) return
if (keepAliveJob?.isActive == true) return
val connRef = connection ?: return
keepAliveJob = keepAliveScope.launch {
logger.info("Starting SSH keepalive every ${intervalMs}ms (SSH_MSG_IGNORE)")
while (isActive) {
delay(intervalMs)
try {
connRef.writeIgnore()
} catch (e: Exception) {
logger.warn("Keepalive failed, stopping: ${e.message}")
break
}
}
}
}

/**
* Disconnect from the SSH server.
*/
Expand All @@ -703,6 +735,9 @@ class SshClient private constructor(
disconnectForwardJob?.cancel()
disconnectForwardJob = null

keepAliveJob?.cancel()
keepAliveJob = null

connection?.close()
connection = null

Expand Down
20 changes: 20 additions & 0 deletions sshlib/src/main/kotlin/org/connectbot/sshlib/SshClientConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class SshClientConfig private constructor(
val rekeyBytesLimit: Long,
val obscureKeystrokeTimingIntervalMs: Long,
val autoDisconnectOnLastChannelClose: Boolean,
val keepAliveIntervalMs: Long,
) {
class Builder {
/**
Expand Down Expand Up @@ -134,6 +135,21 @@ class SshClientConfig private constructor(
*/
var autoDisconnectOnLastChannelClose: Boolean = true

/**
* Send an SSH_MSG_IGNORE heartbeat every N milliseconds to keep the
* connection alive across NAT/VPN/firewall idle timeouts.
*
* The message is a single empty payload that the server silently ignores
* (RFC 4253 §11.2). It does NOT expect a response — this is purely to
* prevent intermediaries from killing the TCP connection during idle.
*
* Recommended for long-lived connections behind aggressive firewalls.
* Set to 0 to disable (default).
*
* Common values: 15000 (15s, sshj default), 30000 (30s).
*/
var keepAliveIntervalMs: Long = 0L

fun build(): SshClientConfig {
val factory = transportFactory ?: run {
require(host.isNotBlank()) { "Host must be specified when using default TCP transport" }
Expand All @@ -143,6 +159,9 @@ class SshClientConfig private constructor(
require(obscureKeystrokeTimingIntervalMs >= 0) {
"obscureKeystrokeTimingIntervalMs must be non-negative"
}
require(keepAliveIntervalMs >= 0) {
"keepAliveIntervalMs must be non-negative"
}

val verifier = hostKeyVerifier
requireNotNull(verifier) { "hostKeyVerifier must be set" }
Expand All @@ -166,6 +185,7 @@ class SshClientConfig private constructor(
rekeyBytesLimit,
obscureKeystrokeTimingIntervalMs,
autoDisconnectOnLastChannelClose,
keepAliveIntervalMs,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,15 @@ class SshConnection(
}
}

/**
* Send an SSH_MSG_IGNORE heartbeat to the server.
* The server silently discards this message (RFC 4253 §11.2).
* Used to keep NAT/VPN/firewall connections alive during idle.
*/
internal suspend fun writeIgnore() {
writePacket(SshEnums.MessageType.SSH_MSG_IGNORE.id().toInt(), byteArrayOf())
}

/**
* Initiate SSH connection.
* Performs SSH version exchange, key exchange, and service negotiation.
Expand Down Expand Up @@ -3019,8 +3028,23 @@ class SshConnection(
*
* @return SessionChannel instance if successful, null otherwise
*/

/**
* Opens a session channel with the given local flow-control parameters.
*
* The initialWindowSize default was raised from 64KB to 16MB: SFTP (which runs over a
* session channel) stalls hard with a small window — the server can only send 64KB of
* data before pausing for SSH_MSG_CHANNEL_WINDOW_ADJUST, i.e. one full round-trip per
* 64KB of transfer. With 16MB the server can keep data in flight without pausing,
* which is what SFTP high-throughput transfers need (measured: a pipelined read of a
* 712MB file went from ~6MB/s to ~30MB/s with the large window).
*
* maxPacketSize stays at 32KB (a safe default that works with OpenSSH servers; a
* larger packet size caused ChannelClosedException when the server responded to a
* large SFTP read with an oversized SSH_MSG_CHANNEL_DATA — see fork history).
*/
suspend fun openSessionChannel(
initialWindowSize: Int = 64 * 1024,
initialWindowSize: Int = 16 * 1024 * 1024,
maxPacketSize: Int = 32 * 1024,
): SessionChannel? {
val localChannelNumber = allocateChannelNumber()
Expand Down
Loading