From 02147902ccaf685d14726267c47974fbde9d3b32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 13 Nov 2026 08:48:37 +0100 Subject: [PATCH 1/2] feat(sftp): add SFTP transfers, port forwarding and bastion sessions `SshSession.open_sftp()` returns an `SftpClient` with list, stat, realpath, mkdir, rename, remove_file and remove_dir, plus chunked upload and download. Transfers stream in 256 KiB pieces, report progress at most ten times a second, honour a cancel token, and stage through `.part` so an interrupted transfer never leaves a truncated file at the destination. `start_local_forward` binds on the device and opens a direct-tcpip channel per connection; `start_remote_forward` requests a server listener and pumps each forwarded-tcpip channel to a device destination. Both return a handle whose `stop()` closes the listener and its connections, and report per-connection failures through a `ForwardSink`. `SshSession.connect_via` runs SSH over a direct-tcpip channel of an authenticated session, so chains of bastions compose. The new categories NotFound, PermissionDenied, Cancelled, Forwarding and LocalIo join the stable error union, and the Kotlin, Swift and TypeScript layers expose all of it. The integration fixtures gain an http role, a bastion and a target container on a private network, and a forwarded-tcpip port. --- .github/workflows/ci.yml | 32 +- .../reactnativessh/ReactNativeSshModule.kt | 193 +++++++- .../java/ai/kilo/reactnativessh/SshErrors.kt | 5 + .../java/ai/kilo/reactnativessh/SshRecords.kt | 20 + example/App.tsx | 2 + example/SshFilesDemo.tsx | 221 +++++++++ ios/ReactNativeSshModule.swift | 218 ++++++++- ios/SshErrors.swift | 18 +- ios/SshEventSinks.swift | 35 ++ ios/SshModuleState.swift | 71 +++ ios/SshRecords.swift | 41 ++ rust/Cargo.lock | 187 ++++++- rust/ssh-core/Cargo.toml | 6 +- rust/ssh-core/src/error.rs | 102 ++++ rust/ssh-core/src/forward.rs | 341 +++++++++++++ rust/ssh-core/src/lib.rs | 5 + rust/ssh-core/src/session.rs | 305 ++++++++++-- rust/ssh-core/src/sftp.rs | 277 +++++++++++ rust/ssh-core/src/shell.rs | 6 +- rust/ssh-core/src/transfer.rs | 327 +++++++++++++ rust/ssh-core/tests/docker/Dockerfile | 2 + rust/ssh-core/tests/docker/entrypoint.sh | 5 + rust/ssh-core/tests/docker/http_response.sh | 5 + .../tests/docker/sshd_config.password | 7 + rust/ssh-core/tests/integration.rs | 320 +----------- rust/ssh-core/tests/support/mod.rs | 462 ++++++++++++++++++ rust/ssh-core/tests/transfers.rs | 392 +++++++++++++++ src/ReactNativeSsh.types.ts | 53 ++ src/ReactNativeSshModule.ts | 39 ++ src/ReactNativeSshModule.web.ts | 86 ++++ src/errors.ts | 52 ++ src/forward.ts | 63 +++ src/index.ts | 25 +- src/sftp.ts | 116 +++++ src/ssh.ts | 97 ++-- 35 files changed, 3688 insertions(+), 448 deletions(-) create mode 100644 example/SshFilesDemo.tsx create mode 100644 ios/SshEventSinks.swift create mode 100644 rust/ssh-core/src/forward.rs create mode 100644 rust/ssh-core/src/sftp.rs create mode 100644 rust/ssh-core/src/transfer.rs create mode 100755 rust/ssh-core/tests/docker/http_response.sh create mode 100644 rust/ssh-core/tests/support/mod.rs create mode 100644 rust/ssh-core/tests/transfers.rs create mode 100644 src/errors.ts create mode 100644 src/forward.ts create mode 100644 src/sftp.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9b3379..686c395 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,12 +44,20 @@ jobs: - name: Build the SSH test server run: docker build -t ssh-core-test -f ssh-core/tests/docker/Dockerfile ssh-core/tests + # One network for the whole fixture: the bastion reaches the target only + # over it, and `ssh-core-http` has no published port at all. Port 9000 is + # published so a remote forward bound inside the container is reachable. - name: Start the SSH test servers run: | - docker run -d --name ssh-core-password -p 2222:2222 ssh-core-test password - docker run -d --name ssh-core-twofactor -p 2223:2222 ssh-core-test two-factor - docker run -d --name ssh-core-silent -p 2224:2222 ssh-core-test silent - for port in 2222 2223 2224; do + set -euo pipefail + docker network create ssh-core-net + docker run -d --name ssh-core-password --network ssh-core-net -p 2222:2222 -p 9000:9000 ssh-core-test password + docker run -d --name ssh-core-twofactor --network ssh-core-net -p 2223:2222 ssh-core-test two-factor + docker run -d --name ssh-core-silent --network ssh-core-net -p 2224:2222 ssh-core-test silent + docker run -d --name ssh-core-http --network ssh-core-net ssh-core-test http + docker run -d --name ssh-core-bastion --network ssh-core-net -p 2226:2222 ssh-core-test password + docker run -d --name ssh-core-target --network ssh-core-net ssh-core-test password + for port in 2222 2223 2224 2226; do for attempt in $(seq 1 30); do if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then break; fi sleep 1 @@ -59,19 +67,29 @@ jobs: - name: Run the integration suite env: SSH_TEST_HOST: 127.0.0.1 - run: cargo test --test integration + SSH_TEST_REMOTE_FORWARD_PORT: '9000' + SSH_TEST_HTTP_HOST: ssh-core-http + SSH_TEST_HTTP_PORT: '8080' + SSH_TEST_BASTION_PORT: '2226' + SSH_TEST_TARGET_HOST: ssh-core-target + SSH_TEST_TARGET_PORT: '2222' + SSH_TEST_THIRD_HOP_HOST: ssh-core-password + SSH_TEST_THIRD_HOP_PORT: '2222' + run: cargo test --test integration --test transfers - name: Show the server logs on failure if: failure() run: | - for name in ssh-core-password ssh-core-twofactor ssh-core-silent; do + for name in ssh-core-password ssh-core-twofactor ssh-core-silent ssh-core-http ssh-core-bastion ssh-core-target; do echo "== $name" docker logs "$name" || true done - name: Stop the SSH test servers if: always() - run: docker rm -f ssh-core-password ssh-core-twofactor ssh-core-silent + run: | + docker rm -f ssh-core-password ssh-core-twofactor ssh-core-silent ssh-core-http ssh-core-bastion ssh-core-target + docker network rm ssh-core-net android: diff --git a/android/src/main/java/ai/kilo/reactnativessh/ReactNativeSshModule.kt b/android/src/main/java/ai/kilo/reactnativessh/ReactNativeSshModule.kt index 0d020c3..d70f7d8 100644 --- a/android/src/main/java/ai/kilo/reactnativessh/ReactNativeSshModule.kt +++ b/android/src/main/java/ai/kilo/reactnativessh/ReactNativeSshModule.kt @@ -2,9 +2,18 @@ package ai.kilo.reactnativessh import ai.kilo.reactnativessh.terminal.TerminalSourceRegistry import ai.kilo.reactnativessh.uniffi.AuthMethod +import ai.kilo.reactnativessh.uniffi.CancelToken import ai.kilo.reactnativessh.uniffi.ConnectConfig +import ai.kilo.reactnativessh.uniffi.Entry +import ai.kilo.reactnativessh.uniffi.EntryKind +import ai.kilo.reactnativessh.uniffi.ForwardConfig +import ai.kilo.reactnativessh.uniffi.ForwardHandle +import ai.kilo.reactnativessh.uniffi.ForwardSink +import ai.kilo.reactnativessh.uniffi.SessionHandler +import ai.kilo.reactnativessh.uniffi.SftpClient import ai.kilo.reactnativessh.uniffi.SshException import ai.kilo.reactnativessh.uniffi.SshSession +import ai.kilo.reactnativessh.uniffi.TransferProgress import ai.kilo.reactnativessh.uniffi.categoryIsRetryable import ai.kilo.reactnativessh.uniffi.coreVersion import ai.kilo.reactnativessh.uniffi.generateEd25519Key @@ -21,18 +30,31 @@ import kotlinx.coroutines.launch internal const val EVENT_HOST_KEY = "onHostKey" internal const val EVENT_KEYBOARD_INTERACTIVE = "onKeyboardInteractive" internal const val EVENT_DISCONNECT = "onDisconnect" +internal const val EVENT_TRANSFER_PROGRESS = "onTransferProgress" +internal const val EVENT_FORWARD_ERROR = "onForwardError" class ReactNativeSshModule : Module() { private val sessions = ConcurrentHashMap() private val shells = ConcurrentHashMap() + private val sftps = ConcurrentHashMap() + private val forwards = ConcurrentHashMap() + private val cancels = ConcurrentHashMap() private val hostKeyRequests = ConcurrentHashMap>() private val promptRequests = ConcurrentHashMap?>>() private val sessionIds = IdSequence("session-") private val shellIds = IdSequence("shell-") + private val sftpIds = IdSequence("sftp-") + private val forwardIds = IdSequence("forward-") override fun definition() = ModuleDefinition { Name("ReactNativeSsh") - Events(EVENT_HOST_KEY, EVENT_KEYBOARD_INTERACTIVE, EVENT_DISCONNECT) + Events( + EVENT_HOST_KEY, + EVENT_KEYBOARD_INTERACTIVE, + EVENT_DISCONNECT, + EVENT_TRANSFER_PROGRESS, + EVENT_FORWARD_ERROR, + ) Function("coreVersion") { coreVersion() @@ -56,7 +78,13 @@ class ReactNativeSshModule : Module() { } AsyncFunction("connect").SuspendBody { config: ConnectConfigRecord -> - connectSession(config) + connectSession(config) { handler -> SshSession.connect(config.toNative(), handler) } + } + + AsyncFunction("connectVia").SuspendBody { jumpSessionId: String, config: ConnectConfigRecord -> + val jump = session(jumpSessionId) + + connectSession(config) { handler -> SshSession.connectVia(jump, config.toNative(), handler) } } AsyncFunction("exec").SuspendBody { sessionId: String, command: String -> @@ -87,16 +115,95 @@ class ReactNativeSshModule : Module() { categoryIsRetryable(code) } + AsyncFunction("openSftp").SuspendBody { sessionId: String -> + val sftpId = sftpIds.next() + sftps[sftpId] = translateSuspendFailures { session(sessionId).openSftp() } + + sftpId + } + + AsyncFunction("closeSftp").SuspendBody { sftpId: String -> + sftps.remove(sftpId)?.let { client -> translateSuspendFailures { client.shutdown() } } + } + + AsyncFunction("sftpList").SuspendBody { sftpId: String, path: String -> + translateSuspendFailures { sftp(sftpId).list(path) }.map(Entry::toRecord) + } + + AsyncFunction("sftpStat").SuspendBody { sftpId: String, path: String -> + translateSuspendFailures { sftp(sftpId).stat(path) }.toRecord() + } + + AsyncFunction("sftpRealpath").SuspendBody { sftpId: String, path: String -> + translateSuspendFailures { sftp(sftpId).realpath(path) } + } + + AsyncFunction("sftpMkdir").SuspendBody { sftpId: String, path: String -> + translateSuspendFailures { sftp(sftpId).mkdir(path) } + } + + AsyncFunction("sftpRename").SuspendBody { sftpId: String, from: String, to: String -> + translateSuspendFailures { sftp(sftpId).rename(from, to) } + } + + AsyncFunction("sftpRemoveFile").SuspendBody { sftpId: String, path: String -> + translateSuspendFailures { sftp(sftpId).removeFile(path) } + } + + AsyncFunction("sftpRemoveDir").SuspendBody { sftpId: String, path: String -> + translateSuspendFailures { sftp(sftpId).removeDir(path) } + } + + AsyncFunction("sftpDownload") + .SuspendBody { sftpId: String, remotePath: String, localPath: String, transferId: String -> + transfer(transferId) { progress, cancel -> + sftp(sftpId).download(remotePath, localPath, progress, cancel) + } + } + + AsyncFunction("sftpUpload") + .SuspendBody { sftpId: String, localPath: String, remotePath: String, transferId: String -> + transfer(transferId) { progress, cancel -> + sftp(sftpId).upload(localPath, remotePath, progress, cancel) + } + } + + Function("cancelTransfer") { transferId: String -> + cancels[transferId]?.cancel() + } + + AsyncFunction("startLocalForward").SuspendBody { sessionId: String, config: ForwardConfigRecord -> + registerForward { + session(sessionId).startLocalForward(config.toNative(), forwardSink(sessionId)) + } + } + + AsyncFunction("startRemoteForward").SuspendBody { sessionId: String, config: ForwardConfigRecord -> + registerForward { + session(sessionId).startRemoteForward(config.toNative(), forwardSink(sessionId)) + } + } + + AsyncFunction("stopForward").SuspendBody { forwardId: String -> + forwards.remove(forwardId)?.let { handle -> translateSuspendFailures { handle.stop() } } + } + OnDestroy { val scope = CoroutineScope(Dispatchers.IO) shells.keys.forEach(::closeShell) + sftps.values.forEach { client -> scope.launch { runCatching { client.shutdown() } } } + forwards.values.forEach { handle -> scope.launch { runCatching { handle.stop() } } } + cancels.values.forEach { token -> token.cancel() } sessions.forEach { (_, session) -> scope.launch { runCatching { session.disconnect() } } } sessions.clear() } } - private suspend fun connectSession(config: ConnectConfigRecord): String { + private suspend fun connectSession( + config: ConnectConfigRecord, + connect: suspend (SessionHandler) -> SshSession, + ): String { val sessionId = sessionIds.next() val handler = JsSessionHandler( sessionId = sessionId, @@ -108,8 +215,7 @@ class ReactNativeSshModule : Module() { }, ) - val session = translateSuspendFailures { SshSession.connect(config.toNative(), handler) } - sessions[sessionId] = session + sessions[sessionId] = translateSuspendFailures { connect(handler) } return sessionId } @@ -156,6 +262,52 @@ class ReactNativeSshModule : Module() { shells.filterValues { it.sessionId == sessionId }.keys.forEach(::closeShell) } + private suspend fun transfer( + transferId: String, + run: suspend (TransferProgress, CancelToken) -> ULong, + ): Double { + val cancel = CancelToken() + cancels[transferId] = cancel + + return try { + translateSuspendFailures { run(progressSink(transferId), cancel) }.toDouble() + } finally { + cancels.remove(transferId) + } + } + + private suspend fun registerForward(start: suspend () -> ForwardHandle): ForwardStartRecord { + val forwardId = forwardIds.next() + val handle = translateSuspendFailures(start) + + forwards[forwardId] = handle + + return ForwardStartRecord(forwardId, handle.actualPort().toInt()) + } + + private fun progressSink(transferId: String) = object : TransferProgress { + override fun onProgress(transferred: ULong, total: ULong?) { + runCatching { + sendEvent( + EVENT_TRANSFER_PROGRESS, + mapOf( + "transferId" to transferId, + "transferred" to transferred.toDouble(), + "total" to total?.toDouble(), + ), + ) + } + } + } + + private fun forwardSink(sessionId: String) = object : ForwardSink { + override fun onError(message: String) { + runCatching { + sendEvent(EVENT_FORWARD_ERROR, mapOf("sessionId" to sessionId, "message" to message)) + } + } + } + private suspend fun requestHostKey(request: HostKeyRequestRecord): Boolean { val decision = CompletableDeferred() hostKeyRequests[request.requestId] = decision @@ -183,6 +335,10 @@ class ReactNativeSshModule : Module() { private fun session(sessionId: String): SshSession = sessions[sessionId] ?: throw SshException.ChannelClosed("session $sessionId is not connected").toCodedException() + + private fun sftp(sftpId: String): SftpClient = + sftps[sftpId] + ?: throw SshException.ChannelClosed("sftp $sftpId is not open").toCodedException() } /** Runs a core call, translating its failure into a coded JavaScript error. */ @@ -198,6 +354,33 @@ private suspend fun translateSuspendFailures(call: suspend () -> T): T = try throw error.toCodedException() } +private fun Entry.toRecord() = SftpEntryRecord( + name = name, + kind = kind.eventName(), + size = size.toDouble(), + modifiedUnix = modifiedUnix?.toDouble(), + permissions = permissions?.toInt(), +) + +private fun EntryKind.eventName(): String = when (this) { + EntryKind.FILE -> "file" + EntryKind.DIRECTORY -> "directory" + EntryKind.SYMLINK -> "symlink" + EntryKind.OTHER -> "other" +} + +private fun ForwardConfigRecord.toNative(): ForwardConfig { + require(bindPort in 0..65535) { "bindPort must be between 0 and 65535" } + require(destPort in 1..65535) { "destPort must be between 1 and 65535" } + + return ForwardConfig( + bindHost = bindHost, + bindPort = bindPort.toUShort(), + destHost = destHost, + destPort = destPort.toUShort(), + ) +} + private fun AuthRecord.toNative(): AuthMethod = when (type) { "password" -> AuthMethod.Password(requireNotNull(password) { "password auth needs a password" }) "key" -> AuthMethod.Key( diff --git a/android/src/main/java/ai/kilo/reactnativessh/SshErrors.kt b/android/src/main/java/ai/kilo/reactnativessh/SshErrors.kt index 24add4e..178224c 100644 --- a/android/src/main/java/ai/kilo/reactnativessh/SshErrors.kt +++ b/android/src/main/java/ai/kilo/reactnativessh/SshErrors.kt @@ -25,4 +25,9 @@ private fun SshException.code(): String = when (this) { is SshException.ChannelClosed -> "ChannelClosed" is SshException.Protocol -> "Protocol" is SshException.KeyGeneration -> "KeyGeneration" + is SshException.NotFound -> "NotFound" + is SshException.PermissionDenied -> "PermissionDenied" + is SshException.Cancelled -> "Cancelled" + is SshException.Forwarding -> "Forwarding" + is SshException.LocalIo -> "LocalIo" } diff --git a/android/src/main/java/ai/kilo/reactnativessh/SshRecords.kt b/android/src/main/java/ai/kilo/reactnativessh/SshRecords.kt index d707600..e266921 100644 --- a/android/src/main/java/ai/kilo/reactnativessh/SshRecords.kt +++ b/android/src/main/java/ai/kilo/reactnativessh/SshRecords.kt @@ -46,6 +46,26 @@ class ExecOutputRecord( @Field val exitStatus: Int?, ) : Record +class SftpEntryRecord( + @Field val name: String, + @Field val kind: String, + @Field val size: Double, + @Field val modifiedUnix: Double?, + @Field val permissions: Int?, +) : Record + +class ForwardConfigRecord : Record { + @Field var bindHost: String = "127.0.0.1" + @Field var bindPort: Int = 0 + @Field var destHost: String = "" + @Field var destPort: Int = 22 +} + +class ForwardStartRecord( + @Field val forwardId: String, + @Field val port: Int, +) : Record + internal class HostKeyRequestRecord( val sessionId: String, val requestId: String, diff --git a/example/App.tsx b/example/App.tsx index c6597ec..4a73dbe 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -2,11 +2,13 @@ import { useState } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import SshDemo from './SshDemo'; +import SshFilesDemo from './SshFilesDemo'; import SshTerminalDemo from './SshTerminalDemo'; import TerminalDemo from './TerminalDemo'; const DEMOS = [ { key: 'ssh-terminal', label: 'SSH terminal', Screen: SshTerminalDemo }, + { key: 'ssh-files', label: 'SSH files', Screen: SshFilesDemo }, { key: 'terminal', label: 'PTY server terminal', Screen: TerminalDemo }, { key: 'ssh', label: 'SSH commands', Screen: SshDemo }, ] as const; diff --git a/example/SshFilesDemo.tsx b/example/SshFilesDemo.tsx new file mode 100644 index 0000000..1720bd3 --- /dev/null +++ b/example/SshFilesDemo.tsx @@ -0,0 +1,221 @@ +import { + SshError, + addSshListener, + answerHostKey, + closeSftp, + connect, + disconnect, + download, + exec, + isRetryableError, + listDirectory, + openSftp, + startLocalForward, +} from '@kilocode/react-native-ssh'; +import type { Forward } from '@kilocode/react-native-ssh'; +import { useEffect, useState } from 'react'; +import { Alert, Button, ScrollView, StyleSheet, Text, View } from 'react-native'; + +/** + * Drives SFTP and a local forward against the containers published by + * `rust/ssh-core/tests/docker` (`adb reverse tcp:2222 tcp:2222` in the example + * app). The forward's destination is resolved from the server, so it names the + * `http` container. + * + * The download target is the example app's own sandbox; an app reads the + * directory from `expo-file-system` and appends the file name. + */ +const SERVER = { + host: '127.0.0.1', + port: 2222, + username: 'user', + password: 'ssh-core-password', +}; +const REMOTE_DIR = '/home/user'; +const REMOTE_SAMPLE = `${REMOTE_DIR}/sftp-demo.txt`; +const SANDBOX_FILES = 'file:///data/user/0/ai.kilo.reactnativessh.example/files'; +const HTTP_FIXTURE = { host: 'ssh-core-http', port: 8080 }; + +export default function SshFilesDemo() { + const [sessionId, setSessionId] = useState(null); + const [sftpId, setSftpId] = useState(null); + const [lines, setLines] = useState([]); + const [error, setError] = useState(null); + + useEffect( + () => + addSshListener('onHostKey', (request) => { + Alert.alert( + 'Unknown host key', + `${request.algorithm}\n${request.fingerprintSha256}`, + [ + { + text: 'Reject', + style: 'cancel', + onPress: () => answerHostKey(request.requestId, false), + }, + { text: 'Accept', onPress: () => answerHostKey(request.requestId, true) }, + ], + { cancelable: false } + ); + }).remove, + [] + ); + + useEffect( + () => + addSshListener('onForwardError', (notice) => { + append(`forward error: ${notice.message}`); + }).remove, + [] + ); + + function append(line: string) { + setLines((current) => [...current, line]); + } + + async function openSession() { + setError(null); + setLines([]); + + try { + const id = await connect({ + host: SERVER.host, + port: SERVER.port, + username: SERVER.username, + auth: { type: 'password', password: SERVER.password }, + }); + setSessionId(id); + setSftpId(await openSftp(id)); + append('session and SFTP channel open'); + } catch (cause) { + setError(describe(cause)); + } + } + + async function listRemote() { + if (!sftpId) { + return; + } + + try { + const entries = await listDirectory(sftpId, REMOTE_DIR); + entries.forEach((entry) => + append(`${entry.kind} ${entry.size} ${entry.name}`) + ); + } catch (cause) { + setError(describe(cause)); + } + } + + async function downloadSample() { + if (!sftpId || !sessionId) { + return; + } + + try { + await exec(sessionId, `printf 'hello from sftp\\n' > ${REMOTE_SAMPLE}`); + const localUrl = `${SANDBOX_FILES}/sftp-demo.txt`; + const written = await download(sftpId, REMOTE_SAMPLE, localUrl, { + onProgress: (progress) => { + append(`downloaded ${progress.transferred}/${progress.total ?? '?'} bytes`); + }, + }); + append(`download complete: ${written} bytes at ${localUrl}`); + } catch (cause) { + setError(describe(cause)); + } + } + + async function fetchThroughForward() { + if (!sessionId) { + return; + } + + let forward: Forward | null = null; + + try { + forward = await startLocalForward(sessionId, { + destHost: HTTP_FIXTURE.host, + destPort: HTTP_FIXTURE.port, + }); + append(`forward listening on 127.0.0.1:${forward.port}`); + + const response = await fetch(`http://127.0.0.1:${forward.port}/`); + append(`forward response: ${await response.text()}`); + } catch (cause) { + setError(describe(cause)); + } finally { + await forward?.stop(); + } + } + + async function closeSession() { + if (sftpId) { + await closeSftp(sftpId); + setSftpId(null); + } + if (sessionId) { + await disconnect(sessionId); + setSessionId(null); + } + } + + return ( + + + SFTP and forwarding + {sessionId ?? 'not connected'} + +