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
32 changes: 25 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
193 changes: 188 additions & 5 deletions android/src/main/java/ai/kilo/reactnativessh/ReactNativeSshModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String, SshSession>()
private val shells = ConcurrentHashMap<String, SshShellSource>()
private val sftps = ConcurrentHashMap<String, SftpClient>()
private val forwards = ConcurrentHashMap<String, ForwardHandle>()
private val cancels = ConcurrentHashMap<String, CancelToken>()
private val hostKeyRequests = ConcurrentHashMap<String, CompletableDeferred<Boolean>>()
private val promptRequests = ConcurrentHashMap<String, CompletableDeferred<List<String>?>>()
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()
Expand All @@ -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 ->
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
Expand Down Expand Up @@ -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<Boolean>()
hostKeyRequests[request.requestId] = decision
Expand Down Expand Up @@ -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. */
Expand All @@ -198,6 +354,33 @@ private suspend fun <T> 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(
Expand Down
5 changes: 5 additions & 0 deletions android/src/main/java/ai/kilo/reactnativessh/SshErrors.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
20 changes: 20 additions & 0 deletions android/src/main/java/ai/kilo/reactnativessh/SshRecords.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions example/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading