From 0a67e036ee0c1b23538b6a4f08450ec883919c67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 13 Nov 2026 04:42:34 +0100 Subject: [PATCH 01/15] feat(ssh): connect, execute and stream shells from the Rust core The core now owns sessions end to end: uniffi `SshSession.connect` runs on a tokio runtime inside the crate, authenticates with a password, a key or keyboard-interactive prompts, and reports every host-key and prompt decision through a `SessionHandler` callback. - `open_shell` streams terminal output into a foreign `ByteSink` in chunk sized copies, so bytes never cross into JavaScript; input and resizes go through one ordered writer task per channel. - `exec` returns stdout, stderr and the exit status; `disconnect` ends the session without a spurious disconnect callback. - `parse_private_key` reads OpenSSH and PKCS#8/PKCS#1/SEC1 PEM (ed25519, RSA, ecdsa p256/p384) and distinguishes a wrong passphrase from an unsupported format. - `SshError` carries the stable categories the binding layers expose; only transport failures are retryable, and a test ties that policy to the exported `category_is_retryable`. - Kotlin and Swift modules expose connect/exec/disconnect/parsePrivateKey with prompt events answered by `answerHostKey`/`answerPrompts`, and `SshShellSource` implements the terminal slice's byte-source contract. - A Docker OpenSSH server (password, two-prompt PAM, silent listener) backs an integration suite gated on `SSH_TEST_HOST`, with a CI job that starts the containers. - TypeScript API with the error category union, and an example screen that asks for the host key in an alert and runs `uname -a`. --- .github/workflows/ci.yml | 46 ++ android/build.gradle | 2 + .../java/ai/kilo/reactnativessh/IdSequence.kt | 10 + .../kilo/reactnativessh/JsSessionHandler.kt | 56 ++ .../reactnativessh/ReactNativeSshModule.kt | 215 ++++++- .../java/ai/kilo/reactnativessh/SshErrors.kt | 28 + .../java/ai/kilo/reactnativessh/SshRecords.kt | 91 +++ .../ai/kilo/reactnativessh/SshShellSource.kt | 135 ++++ example/App.tsx | 61 +- example/SshDemo.tsx | 127 ++++ ios/JsSessionHandler.swift | 69 ++ ios/ReactNativeSshModule.swift | 234 ++++++- ios/SshErrors.swift | 60 ++ ios/SshModuleState.swift | 85 +++ ios/SshRecords.swift | 106 ++++ ios/SshShellSource.swift | 108 ++++ ios/SshSupport.swift | 69 ++ rust/ssh-core/Cargo.toml | 8 +- rust/ssh-core/src/error.rs | 279 ++++++++ rust/ssh-core/src/key.rs | 300 +++++++++ rust/ssh-core/src/lib.rs | 94 +-- rust/ssh-core/src/runtime.rs | 51 ++ rust/ssh-core/src/session.rs | 471 ++++++++++++++ rust/ssh-core/src/shell.rs | 128 ++++ rust/ssh-core/tests/docker/Dockerfile | 43 ++ rust/ssh-core/tests/docker/entrypoint.sh | 25 + rust/ssh-core/tests/docker/pam_two_factor.c | 54 ++ .../tests/docker/sshd_config.password | 23 + .../tests/docker/sshd_config.twofactor | 18 + rust/ssh-core/tests/fixtures/id_ecdsa_p256 | 9 + .../tests/fixtures/id_ecdsa_p256.pk8.pem | 5 + .../ssh-core/tests/fixtures/id_ecdsa_p256.pub | 1 + .../tests/fixtures/id_ecdsa_p256.sec1.pem | 10 + rust/ssh-core/tests/fixtures/id_ecdsa_p384 | 10 + .../ssh-core/tests/fixtures/id_ecdsa_p384.pub | 1 + rust/ssh-core/tests/fixtures/id_ed25519 | 7 + .../tests/fixtures/id_ed25519.pk8.pem | 3 + rust/ssh-core/tests/fixtures/id_ed25519.pub | 1 + .../tests/fixtures/id_ed25519_encrypted | 8 + .../tests/fixtures/id_ed25519_encrypted.pub | 1 + rust/ssh-core/tests/fixtures/id_rsa | 27 + .../tests/fixtures/id_rsa.pk8.encrypted.pem | 30 + rust/ssh-core/tests/fixtures/id_rsa.pk8.pem | 28 + rust/ssh-core/tests/fixtures/id_rsa.pkcs1.pem | 27 + rust/ssh-core/tests/fixtures/id_rsa.pub | 1 + rust/ssh-core/tests/fixtures/unsupported.pem | 17 + rust/ssh-core/tests/integration.rs | 594 ++++++++++++++++++ src/ReactNativeSsh.types.ts | 131 ++++ src/ReactNativeSshModule.ts | 21 +- src/ReactNativeSshModule.web.ts | 53 +- src/index.ts | 26 + src/ssh.ts | 172 +++++ 52 files changed, 4013 insertions(+), 166 deletions(-) create mode 100644 android/src/main/java/ai/kilo/reactnativessh/IdSequence.kt create mode 100644 android/src/main/java/ai/kilo/reactnativessh/JsSessionHandler.kt create mode 100644 android/src/main/java/ai/kilo/reactnativessh/SshErrors.kt create mode 100644 android/src/main/java/ai/kilo/reactnativessh/SshRecords.kt create mode 100644 android/src/main/java/ai/kilo/reactnativessh/SshShellSource.kt create mode 100644 example/SshDemo.tsx create mode 100644 ios/JsSessionHandler.swift create mode 100644 ios/SshErrors.swift create mode 100644 ios/SshModuleState.swift create mode 100644 ios/SshRecords.swift create mode 100644 ios/SshShellSource.swift create mode 100644 ios/SshSupport.swift create mode 100644 rust/ssh-core/src/error.rs create mode 100644 rust/ssh-core/src/key.rs create mode 100644 rust/ssh-core/src/runtime.rs create mode 100644 rust/ssh-core/src/session.rs create mode 100644 rust/ssh-core/src/shell.rs create mode 100644 rust/ssh-core/tests/docker/Dockerfile create mode 100755 rust/ssh-core/tests/docker/entrypoint.sh create mode 100644 rust/ssh-core/tests/docker/pam_two_factor.c create mode 100644 rust/ssh-core/tests/docker/sshd_config.password create mode 100644 rust/ssh-core/tests/docker/sshd_config.twofactor create mode 100644 rust/ssh-core/tests/fixtures/id_ecdsa_p256 create mode 100644 rust/ssh-core/tests/fixtures/id_ecdsa_p256.pk8.pem create mode 100644 rust/ssh-core/tests/fixtures/id_ecdsa_p256.pub create mode 100644 rust/ssh-core/tests/fixtures/id_ecdsa_p256.sec1.pem create mode 100644 rust/ssh-core/tests/fixtures/id_ecdsa_p384 create mode 100644 rust/ssh-core/tests/fixtures/id_ecdsa_p384.pub create mode 100644 rust/ssh-core/tests/fixtures/id_ed25519 create mode 100644 rust/ssh-core/tests/fixtures/id_ed25519.pk8.pem create mode 100644 rust/ssh-core/tests/fixtures/id_ed25519.pub create mode 100644 rust/ssh-core/tests/fixtures/id_ed25519_encrypted create mode 100644 rust/ssh-core/tests/fixtures/id_ed25519_encrypted.pub create mode 100644 rust/ssh-core/tests/fixtures/id_rsa create mode 100644 rust/ssh-core/tests/fixtures/id_rsa.pk8.encrypted.pem create mode 100644 rust/ssh-core/tests/fixtures/id_rsa.pk8.pem create mode 100644 rust/ssh-core/tests/fixtures/id_rsa.pkcs1.pem create mode 100644 rust/ssh-core/tests/fixtures/id_rsa.pub create mode 100644 rust/ssh-core/tests/fixtures/unsupported.pem create mode 100644 rust/ssh-core/tests/integration.rs create mode 100644 src/ssh.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2944100..a1e0c4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,52 @@ jobs: - run: cargo clippy --all-targets -- -D warnings - run: cargo test + rust-integration: + name: Rust integration + runs-on: ubuntu-latest + defaults: + run: + working-directory: rust + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rust + + - name: Build the SSH test server + run: docker build -t ssh-core-test -f ssh-core/tests/docker/Dockerfile ssh-core/tests + + - 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 + 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 + done + done + + - name: Run the integration suite + env: + SSH_TEST_HOST: 127.0.0.1 + run: cargo test --test integration + + - name: Show the server logs on failure + if: failure() + run: | + for name in ssh-core-password ssh-core-twofactor ssh-core-silent; 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 + + android: name: Android runs-on: ubuntu-latest diff --git a/android/build.gradle b/android/build.gradle index 444ff15..5dc493d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -20,4 +20,6 @@ android { dependencies { // The uniffi Kotlin bindings call into libssh_core.so through JNA direct mapping. implementation 'net.java.dev.jna:jna:5.17.0@aar' + // The bindings expose async functions and callback interfaces as coroutines. + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2' } diff --git a/android/src/main/java/ai/kilo/reactnativessh/IdSequence.kt b/android/src/main/java/ai/kilo/reactnativessh/IdSequence.kt new file mode 100644 index 0000000..8526d67 --- /dev/null +++ b/android/src/main/java/ai/kilo/reactnativessh/IdSequence.kt @@ -0,0 +1,10 @@ +package ai.kilo.reactnativessh + +import java.util.concurrent.atomic.AtomicLong + +/** Process-local identifiers for sessions, shells and prompt requests. */ +internal class IdSequence(private val prefix: String) { + private val counter = AtomicLong() + + fun next(): String = "$prefix${counter.incrementAndGet()}" +} diff --git a/android/src/main/java/ai/kilo/reactnativessh/JsSessionHandler.kt b/android/src/main/java/ai/kilo/reactnativessh/JsSessionHandler.kt new file mode 100644 index 0000000..7afab78 --- /dev/null +++ b/android/src/main/java/ai/kilo/reactnativessh/JsSessionHandler.kt @@ -0,0 +1,56 @@ +package ai.kilo.reactnativessh + +import ai.kilo.reactnativessh.uniffi.Prompt +import ai.kilo.reactnativessh.uniffi.SessionHandler + +/** + * Bridges the core's session decisions to JavaScript events. + * + * Each request is registered before the event is emitted and removed as soon + * as the decision arrives, so `answerHostKey`/`answerPrompts` never see a stale + * id and a dropped connection releases the pending wait. + */ +internal class JsSessionHandler( + private val sessionId: String, + private val requestHostKey: suspend (HostKeyRequestRecord) -> Boolean, + private val requestPrompts: suspend (KeyboardInteractiveRequestRecord) -> List?, + private val notifyDisconnect: (DisconnectRecord) -> Unit, +) : SessionHandler { + override suspend fun verifyHostKey( + host: String, + port: UShort, + algorithm: String, + publicKeyBase64: String, + fingerprintSha256: String, + ): Boolean = requestHostKey( + HostKeyRequestRecord( + sessionId = sessionId, + requestId = requestIds.next(), + host = host, + port = port.toInt(), + algorithm = algorithm, + publicKeyBase64 = publicKeyBase64, + fingerprintSha256 = fingerprintSha256, + ) + ) + + override suspend fun keyboardInteractive( + name: String, + instructions: String, + prompts: List, + ): List? = requestPrompts( + KeyboardInteractiveRequestRecord( + sessionId = sessionId, + requestId = requestIds.next(), + name = name, + instructions = instructions, + prompts = prompts.map { PromptRecord(it.text, it.echo) }, + ) + ) + + override fun onDisconnect(reason: String) { + notifyDisconnect(DisconnectRecord(sessionId, reason)) + } + + private val requestIds = IdSequence("request-") +} diff --git a/android/src/main/java/ai/kilo/reactnativessh/ReactNativeSshModule.kt b/android/src/main/java/ai/kilo/reactnativessh/ReactNativeSshModule.kt index df60e5d..0d020c3 100644 --- a/android/src/main/java/ai/kilo/reactnativessh/ReactNativeSshModule.kt +++ b/android/src/main/java/ai/kilo/reactnativessh/ReactNativeSshModule.kt @@ -1,25 +1,224 @@ package ai.kilo.reactnativessh -import ai.kilo.reactnativessh.uniffi.coreVersion as coreVersionNative -import ai.kilo.reactnativessh.uniffi.generateEd25519Key as generateEd25519KeyNative +import ai.kilo.reactnativessh.terminal.TerminalSourceRegistry +import ai.kilo.reactnativessh.uniffi.AuthMethod +import ai.kilo.reactnativessh.uniffi.ConnectConfig +import ai.kilo.reactnativessh.uniffi.SshException +import ai.kilo.reactnativessh.uniffi.SshSession +import ai.kilo.reactnativessh.uniffi.categoryIsRetryable +import ai.kilo.reactnativessh.uniffi.coreVersion +import ai.kilo.reactnativessh.uniffi.generateEd25519Key +import ai.kilo.reactnativessh.uniffi.parsePrivateKey +import expo.modules.kotlin.exception.CodedException import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +internal const val EVENT_HOST_KEY = "onHostKey" +internal const val EVENT_KEYBOARD_INTERACTIVE = "onKeyboardInteractive" +internal const val EVENT_DISCONNECT = "onDisconnect" class ReactNativeSshModule : Module() { + private val sessions = ConcurrentHashMap() + private val shells = ConcurrentHashMap() + private val hostKeyRequests = ConcurrentHashMap>() + private val promptRequests = ConcurrentHashMap?>>() + private val sessionIds = IdSequence("session-") + private val shellIds = IdSequence("shell-") + override fun definition() = ModuleDefinition { Name("ReactNativeSsh") + Events(EVENT_HOST_KEY, EVENT_KEYBOARD_INTERACTIVE, EVENT_DISCONNECT) Function("coreVersion") { - coreVersionNative() + coreVersion() } AsyncFunction("generateEd25519Key") { comment: String -> - val key = generateEd25519KeyNative(comment) - mapOf( - "privateKeyOpenssh" to key.privateKeyOpenssh, - "publicKeyOpenssh" to key.publicKeyOpenssh, - "fingerprintSha256" to key.fingerprintSha256, + val key = translateFailures { generateEd25519Key(comment) } + + GeneratedKeyRecord(key.privateKeyOpenssh, key.publicKeyOpenssh, key.fingerprintSha256) + } + + Function("parsePrivateKey") { pem: String, passphrase: String? -> + val key = translateFailures { parsePrivateKey(pem, passphrase) } + + ParsedKeyRecord( + algorithm = key.algorithm, + publicKeyOpenssh = key.publicKeyOpenssh, + fingerprintSha256 = key.fingerprintSha256, + encrypted = key.encrypted, ) } + + AsyncFunction("connect").SuspendBody { config: ConnectConfigRecord -> + connectSession(config) + } + + AsyncFunction("exec").SuspendBody { sessionId: String, command: String -> + runCommand(sessionId, command) + } + + AsyncFunction("disconnect").SuspendBody { sessionId: String -> + disconnectSession(sessionId) + } + + AsyncFunction("openShell").SuspendBody { sessionId: String, options: ShellOptionsRecord -> + openShell(sessionId, options) + } + + Function("closeShell") { shellId: String -> + closeShell(shellId) + } + + Function("answerHostKey") { requestId: String, accept: Boolean -> + hostKeyRequests[requestId]?.complete(accept) == true + } + + Function("answerPrompts") { requestId: String, answers: List? -> + promptRequests[requestId]?.complete(answers) == true + } + + Function("isRetryableCode") { code: String -> + categoryIsRetryable(code) + } + + OnDestroy { + val scope = CoroutineScope(Dispatchers.IO) + + shells.keys.forEach(::closeShell) + sessions.forEach { (_, session) -> scope.launch { runCatching { session.disconnect() } } } + sessions.clear() + } + } + + private suspend fun connectSession(config: ConnectConfigRecord): String { + val sessionId = sessionIds.next() + val handler = JsSessionHandler( + sessionId = sessionId, + requestHostKey = ::requestHostKey, + requestPrompts = ::requestPrompts, + notifyDisconnect = { record -> + // The module can be gone already when a socket dies during teardown. + runCatching { sendEvent(EVENT_DISCONNECT, record.toEvent()) } + }, + ) + + val session = translateSuspendFailures { SshSession.connect(config.toNative(), handler) } + sessions[sessionId] = session + + return sessionId + } + + private suspend fun runCommand(sessionId: String, command: String): ExecOutputRecord { + val session = session(sessionId) + val result = translateSuspendFailures { session.exec(command) } + + return ExecOutputRecord( + stdout = result.stdout.toString(Charsets.UTF_8), + stderr = result.stderr.toString(Charsets.UTF_8), + exitStatus = result.exitStatus?.toInt(), + ) } + + private suspend fun disconnectSession(sessionId: String) { + val session = sessions.remove(sessionId) ?: return + + closeShells(sessionId) + translateSuspendFailures { session.disconnect() } + } + + private suspend fun openShell(sessionId: String, options: ShellOptionsRecord): String { + val session = session(sessionId) + val shellId = shellIds.next() + val source = translateSuspendFailures { + SshShellSource.open(sessionId, session, options.term, options.cols, options.rows) + } + + shells[shellId] = source + TerminalSourceRegistry.register(shellId, source) + + return shellId + } + + private fun closeShell(shellId: String) { + val source = shells.remove(shellId) ?: return + + TerminalSourceRegistry.unregister(shellId) + source.close() + } + + private fun closeShells(sessionId: String) { + shells.filterValues { it.sessionId == sessionId }.keys.forEach(::closeShell) + } + + private suspend fun requestHostKey(request: HostKeyRequestRecord): Boolean { + val decision = CompletableDeferred() + hostKeyRequests[request.requestId] = decision + + return try { + sendEvent(EVENT_HOST_KEY, request.toEvent()) + decision.await() + } finally { + hostKeyRequests.remove(request.requestId) + } + } + + private suspend fun requestPrompts(request: KeyboardInteractiveRequestRecord): List? { + val answers = CompletableDeferred?>() + promptRequests[request.requestId] = answers + + return try { + sendEvent(EVENT_KEYBOARD_INTERACTIVE, request.toEvent()) + answers.await() + } finally { + promptRequests.remove(request.requestId) + } + } + + private fun session(sessionId: String): SshSession = + sessions[sessionId] + ?: throw SshException.ChannelClosed("session $sessionId is not connected").toCodedException() +} + +/** Runs a core call, translating its failure into a coded JavaScript error. */ +private inline fun translateFailures(call: () -> T): T = try { + call() +} catch (error: SshException) { + throw error.toCodedException() +} + +private suspend fun translateSuspendFailures(call: suspend () -> T): T = try { + call() +} catch (error: SshException) { + throw error.toCodedException() +} + +private fun AuthRecord.toNative(): AuthMethod = when (type) { + "password" -> AuthMethod.Password(requireNotNull(password) { "password auth needs a password" }) + "key" -> AuthMethod.Key( + privateKeyOpenssh = requireNotNull(privateKeyOpenssh) { "key auth needs privateKeyOpenssh" }, + passphrase = passphrase, + ) + "keyboardInteractive" -> AuthMethod.KeyboardInteractive + else -> throw CodedException("Protocol", "unknown auth type: $type", null) +} + +private fun ConnectConfigRecord.toNative(): ConnectConfig { + require(port in 1..65535) { "port must be between 1 and 65535" } + require(connectTimeoutMs > 0) { "connectTimeoutMs must be positive" } + require(keepaliveIntervalS >= 0) { "keepaliveIntervalS must not be negative" } + + return ConnectConfig( + host = host, + port = port.toUShort(), + username = username, + auth = auth.toNative(), + connectTimeoutMs = connectTimeoutMs.toUInt(), + keepaliveIntervalS = keepaliveIntervalS.toUInt(), + ) } diff --git a/android/src/main/java/ai/kilo/reactnativessh/SshErrors.kt b/android/src/main/java/ai/kilo/reactnativessh/SshErrors.kt new file mode 100644 index 0000000..24add4e --- /dev/null +++ b/android/src/main/java/ai/kilo/reactnativessh/SshErrors.kt @@ -0,0 +1,28 @@ +package ai.kilo.reactnativessh + +import ai.kilo.reactnativessh.uniffi.SshException +import expo.modules.kotlin.exception.CodedException + +/** + * Hands a Rust failure to JavaScript with the core's error category as `code`. + * + * The categories are the generated variants of [SshException], so this `when` + * stays exhaustive: a new Rust category breaks the build here instead of + * reaching JavaScript as `Protocol`. + */ +internal fun SshException.toCodedException(): CodedException = + CodedException(code(), message.orEmpty(), this) + +private fun SshException.code(): String = when (this) { + is SshException.Network -> "Network" + is SshException.Timeout -> "Timeout" + is SshException.HostKeyRejected -> "HostKeyRejected" + is SshException.AuthFailed -> "AuthFailed" + is SshException.AuthCancelled -> "AuthCancelled" + is SshException.UnsupportedAlgorithm -> "UnsupportedAlgorithm" + is SshException.KeyParse -> "KeyParse" + is SshException.WrongPassphrase -> "WrongPassphrase" + is SshException.ChannelClosed -> "ChannelClosed" + is SshException.Protocol -> "Protocol" + is SshException.KeyGeneration -> "KeyGeneration" +} diff --git a/android/src/main/java/ai/kilo/reactnativessh/SshRecords.kt b/android/src/main/java/ai/kilo/reactnativessh/SshRecords.kt new file mode 100644 index 0000000..d707600 --- /dev/null +++ b/android/src/main/java/ai/kilo/reactnativessh/SshRecords.kt @@ -0,0 +1,91 @@ +package ai.kilo.reactnativessh + +import expo.modules.kotlin.records.Field +import expo.modules.kotlin.records.Record + +/** What JavaScript passes to `connect`. */ +class ConnectConfigRecord : Record { + @Field var host: String = "" + @Field var port: Int = 22 + @Field var username: String = "" + @Field var auth: AuthRecord = AuthRecord() + @Field var connectTimeoutMs: Int = 15_000 + @Field var keepaliveIntervalS: Int = 15 +} + +/** A tagged union in one record: `type` selects which field is read. */ +class AuthRecord : Record { + @Field var type: String = "" + @Field var password: String? = null + @Field var privateKeyOpenssh: String? = null + @Field var passphrase: String? = null +} + +class ShellOptionsRecord : Record { + @Field var term: String = "xterm-256color" + @Field var cols: Int = 80 + @Field var rows: Int = 24 +} + +class GeneratedKeyRecord( + @Field val privateKeyOpenssh: String, + @Field val publicKeyOpenssh: String, + @Field val fingerprintSha256: String, +) : Record + +class ParsedKeyRecord( + @Field val algorithm: String, + @Field val publicKeyOpenssh: String, + @Field val fingerprintSha256: String, + @Field val encrypted: Boolean, +) : Record + +class ExecOutputRecord( + @Field val stdout: String, + @Field val stderr: String, + @Field val exitStatus: Int?, +) : Record + +internal class HostKeyRequestRecord( + val sessionId: String, + val requestId: String, + val host: String, + val port: Int, + val algorithm: String, + val publicKeyBase64: String, + val fingerprintSha256: String, +) { + fun toEvent(): Map = mapOf( + "sessionId" to sessionId, + "requestId" to requestId, + "host" to host, + "port" to port, + "algorithm" to algorithm, + "publicKeyBase64" to publicKeyBase64, + "fingerprintSha256" to fingerprintSha256, + ) +} + +internal class PromptRecord(val text: String, val echo: Boolean) { + fun toEvent(): Map = mapOf("text" to text, "echo" to echo) +} + +internal class KeyboardInteractiveRequestRecord( + val sessionId: String, + val requestId: String, + val name: String, + val instructions: String, + val prompts: List, +) { + fun toEvent(): Map = mapOf( + "sessionId" to sessionId, + "requestId" to requestId, + "name" to name, + "instructions" to instructions, + "prompts" to prompts.map { it.toEvent() }, + ) +} + +internal class DisconnectRecord(val sessionId: String, val reason: String) { + fun toEvent(): Map = mapOf("sessionId" to sessionId, "reason" to reason) +} diff --git a/android/src/main/java/ai/kilo/reactnativessh/SshShellSource.kt b/android/src/main/java/ai/kilo/reactnativessh/SshShellSource.kt new file mode 100644 index 0000000..f72dbf7 --- /dev/null +++ b/android/src/main/java/ai/kilo/reactnativessh/SshShellSource.kt @@ -0,0 +1,135 @@ +package ai.kilo.reactnativessh + +import ai.kilo.reactnativessh.terminal.TerminalByteSource +import ai.kilo.reactnativessh.terminal.TerminalSink +import ai.kilo.reactnativessh.uniffi.ByteSink +import ai.kilo.reactnativessh.uniffi.ShellChannel +import ai.kilo.reactnativessh.uniffi.SshException +import ai.kilo.reactnativessh.uniffi.SshSession + +/** + * A shell channel as the terminal view sees it: bytes go straight from Rust to + * the view, and input comes back the same way. Nothing crosses JavaScript. + */ +internal class SshShellSource private constructor( + val sessionId: String, + private val channel: ShellChannel, + private val delivery: SinkDelivery, +) : TerminalByteSource { + override fun write(bytes: ByteArray) { + try { + channel.write(bytes) + } catch (_: SshException.ChannelClosed) { + // The view can still type after the remote shell exits; the close event + // is the error path. + } + } + + override fun resize(cols: Int, rows: Int) { + try { + channel.resize(cols.toUInt(), rows.toUInt()) + } catch (_: SshException.ChannelClosed) { + // See write. + } + } + + override fun setSink(sink: TerminalSink?) { + delivery.bind(sink) + } + + fun close() { + try { + channel.close() + } catch (_: SshException.ChannelClosed) { + // Already closed by the server. + } + } + + companion object { + suspend fun open( + sessionId: String, + session: SshSession, + term: String, + cols: Int, + rows: Int, + ): SshShellSource { + val delivery = SinkDelivery() + val channel = session.openShell(term, cols.toUInt(), rows.toUInt(), delivery) + + return SshShellSource(sessionId, channel, delivery) + } + } +} + +/** + * Bridges the core's output callback to the view's sink. + * + * The view attaches after the shell is opened, so bytes that arrive first are + * buffered up to [MAX_BUFFERED_BYTES] and delivered on `bind`. + */ +internal class SinkDelivery : ByteSink { + private val lock = Any() + private var sink: TerminalSink? = null + private var buffered: MutableList? = ArrayList() + private var bufferedBytes = 0 + private var closeEvent: CloseEvent? = null + + fun bind(next: TerminalSink?) { + val pending: List + val closed: CloseEvent? + + synchronized(lock) { + sink = next + pending = buffered ?: emptyList() + closed = closeEvent + buffered = null + } + + if (next == null) { + return + } + + pending.forEach(next::onData) + closed?.let { next.onClosed(it.exitStatus, it.error) } + } + + override fun onData(bytes: ByteArray) { + val target = synchronized(lock) { + val current = sink + if (current == null) { + buffer(bytes) + } + current + } + + target?.onData(bytes) + } + + override fun onClosed(exitStatus: UInt?, error: String?) { + val target = synchronized(lock) { + val current = sink + if (current == null) { + closeEvent = CloseEvent(exitStatus?.toInt(), error) + } + current + } + + target?.onClosed(exitStatus?.toInt(), error) + } + + private fun buffer(bytes: ByteArray) { + val pending = buffered ?: return + if (bufferedBytes + bytes.size > MAX_BUFFERED_BYTES) { + return + } + + pending.add(bytes) + bufferedBytes += bytes.size + } + + private class CloseEvent(val exitStatus: Int?, val error: String?) + + private companion object { + const val MAX_BUFFERED_BYTES = 64 * 1024 + } +} diff --git a/example/App.tsx b/example/App.tsx index f96e016..88afdcf 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -1,62 +1,5 @@ -import { useEffect, useState } from 'react'; -import { ScrollView, StyleSheet, Text, View } from 'react-native'; - -import ReactNativeSsh, { type GeneratedKey } from '@kilocode/react-native-ssh'; +import SshDemo from './SshDemo'; export default function App() { - const [version, setVersion] = useState(null); - const [key, setKey] = useState(null); - const [error, setError] = useState(null); - - useEffect(() => { - let cancelled = false; - - try { - setVersion(ReactNativeSsh.coreVersion()); - } catch (cause) { - setError(cause instanceof Error ? cause.message : String(cause)); - return; - } - - ReactNativeSsh.generateEd25519Key('react-native-ssh-example') - .then((generated) => { - if (!cancelled) { - setKey(generated); - } - }) - .catch((cause: unknown) => { - if (!cancelled) { - setError(cause instanceof Error ? cause.message : String(cause)); - } - }); - - return () => { - cancelled = true; - }; - }, []); - - return ( - - - Rust core version - {version ?? 'loading'} - - {key ? ( - <> - Generated Ed25519 key - {key.publicKeyOpenssh} - {key.fingerprintSha256} - - ) : null} - - {error ? {error} : null} - - - ); + return ; } - -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: '#f2f2f2', paddingTop: 72 }, - content: { padding: 20, gap: 12 }, - heading: { fontSize: 16, fontWeight: '600' }, -}); \ No newline at end of file diff --git a/example/SshDemo.tsx b/example/SshDemo.tsx new file mode 100644 index 0000000..f64bbfb --- /dev/null +++ b/example/SshDemo.tsx @@ -0,0 +1,127 @@ +import { + SshError, + addSshListener, + answerHostKey, + connect, + disconnect, + exec, + isRetryableError, +} from '@kilocode/react-native-ssh'; +import { useEffect, useState } from 'react'; +import { Alert, Button, ScrollView, StyleSheet, Text, View } from 'react-native'; + +/** + * Connects to the test server published by `rust/ssh-core/tests/docker` + * (`adb reverse tcp:2222 tcp:2222` in the example app), asks for the host key + * in an alert, then runs `uname -a` over the session. + */ +const SERVER = { + host: '127.0.0.1', + port: 2222, + username: 'user', + password: 'ssh-core-password', +}; + +export default function SshDemo() { + const [sessionId, setSessionId] = useState(null); + const [output, setOutput] = 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('onDisconnect', (notice) => { + setOutput((lines) => [...lines, `disconnected: ${notice.reason}`]); + setSessionId((current) => (current === notice.sessionId ? null : current)); + }).remove, + [] + ); + + async function runCommand() { + setError(null); + setOutput([]); + + try { + const id = await connect({ + host: SERVER.host, + port: SERVER.port, + username: SERVER.username, + auth: { type: 'password', password: SERVER.password }, + }); + setSessionId(id); + + const result = await exec(id, 'uname -a'); + setOutput([result.stdout.trim(), `exit ${result.exitStatus ?? 'unknown'}`]); + } catch (cause) { + setError(describe(cause)); + } + } + + async function closeSession() { + if (!sessionId) { + return; + } + + await disconnect(sessionId); + setSessionId(null); + } + + return ( + + + SSH session + {sessionId ?? 'not connected'} + +