diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43060b5..ec9940a 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'} + +