diff --git a/app/src/main/java/com/secrm/SecureRemoteApp.kt b/app/src/main/java/com/secrm/SecureRemoteApp.kt index 68f4410..eb36abb 100644 --- a/app/src/main/java/com/secrm/SecureRemoteApp.kt +++ b/app/src/main/java/com/secrm/SecureRemoteApp.kt @@ -97,9 +97,13 @@ class SecureRemoteApp : Application() { McpServerService.noteActivity() } }) - mcpSessions = SshTools.register(dispatcher, connections, newClient()) { - snippets.list().map { NamedCommand(it.name, it.command) } - } + mcpSessions = SshTools.register( + dispatcher, connections, newClient(), + commands = { snippets.list().map { NamedCommand(it.name, it.command) } }, + // Public Download, so files fetched over MCP can be shared or attached from the phone. + downloadDir = { java.io.File(android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS), "SecureRemote") }, + onDownloaded = { files -> android.media.MediaScannerConnection.scanFile(this, files.map { it.path }.toTypedArray(), null, null) }, + ) watchNetwork() } diff --git a/core/ssh-tools/src/main/kotlin/com/secrm/core/ssh/tools/SshTools.kt b/core/ssh-tools/src/main/kotlin/com/secrm/core/ssh/tools/SshTools.kt index ac2326f..cf0c5b1 100644 --- a/core/ssh-tools/src/main/kotlin/com/secrm/core/ssh/tools/SshTools.kt +++ b/core/ssh-tools/src/main/kotlin/com/secrm/core/ssh/tools/SshTools.kt @@ -2,14 +2,18 @@ package com.secrm.core.ssh.tools import com.secrm.core.domain.* import com.secrm.core.ssh.SshAuth +import com.secrm.core.ssh.SftpClient import com.secrm.core.ssh.SshClientManager import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.* +import java.io.File object SshTools { @@ -35,6 +39,9 @@ object SshTools { return if (start - 1 + need <= len) len else start - 1 } + private val FOLDER_NAME = Regex("[A-Za-z0-9._-]{1,64}") + private const val MAX_DOWNLOAD_FILES = 5_000 + private val SERVICE_NAME = Regex("[A-Za-z0-9@._:][A-Za-z0-9@._:-]{0,127}") /** Which owner flag a tool needs: `allowMcpExec` for running commands, `allowMcpRead` for fixed read-only queries. */ @@ -48,6 +55,10 @@ object SshTools { saved: SavedConnections, ssh: SshClientManager, commands: () -> List = { emptyList() }, + /** Phone folder that ssh.download writes under; null leaves the tool unavailable. */ + downloadDir: (() -> File)? = null, + /** Told about each file ssh.download saved, e.g. so the media index lists it. */ + onDownloaded: (List) -> Unit = {}, ): McpSessionPool { val pool = McpSessionPool(CoroutineScope(Dispatchers.IO + SupervisorJob()), saved) { // A separate client, so this neither replaces nor drops the app's live session. @@ -258,6 +269,54 @@ object SshTools { } ) + dispatcher.register( + ToolSpec( + name = "ssh.download", + description = "Copy a file, or a folder and everything under it, from a saved connection to the phone over " + + "SFTP, into Download/SecureRemote/. Files already there at the same size are kept, so a rerun resumes.", + inputSchema = schema( + required = listOf("connection", "remote_path"), + "connection" to field("string", CONNECTION_DOC), + "remote_path" to field("string", "File or folder on the server. Symlinks inside a folder are skipped."), + "folder" to field("string", "Subfolder on the phone: letters, digits, . _ - (max 64). Default: the connection name."), + "max_bytes" to field("integer", "Refuses before copying if the total is larger. Clamped to 1..17179869184. Default 4294967296."), + "timeout_ms" to field("integer", "Clamped to 1000..3600000. Default 600000."), + ), + annotations = ToolSpec.Annotations(openWorldHint = true) + ), + object : ToolHandler { + override suspend fun call(session: Session, args: JsonObject): ToolResult { + val root = downloadDir ?: return ToolResult.error("not_available", + "Downloads are not set up in this build.", "No download folder configured.") + val remote = args.str("remote_path") ?: return missingArg("remote_path") + val folder = args.str("folder") ?: args.str("connection")?.replace(Regex("[^A-Za-z0-9._-]"), "_")?.take(64) + ?: return missingArg("connection") + if (!FOLDER_NAME.matches(folder) || folder.all { it == '.' }) return ToolResult.error("invalid_argument", + "Use letters, digits, '.', '_' or '-' (max 64).", "Invalid folder: $folder") + val maxBytes = args.long("max_bytes", 4L shl 30, 1L..(16L shl 30)) + val timeout = args.long("timeout_ms", 600_000L, 1_000L..3_600_000L) + // Copying off the server runs no command there, so read access is enough. + return onConnection(saved, pool, args, Need.READ, timeout) { client -> + val dest = File(root(), folder) + val job = currentCoroutineContext()[Job] + val sftp = SftpClient(client) + val r = try { + sftp.downloadTree(remote, dest, MAX_DOWNLOAD_FILES, maxBytes) { job?.isActive == false } + } catch (e: IllegalArgumentException) { + return@onConnection ToolResult.error("too_large_or_invalid", "Pick a smaller path or raise max_bytes.", e.message ?: "refused") + } finally { sftp.close() } + runCatching { onDownloaded(dest.walkTopDown().filter { it.isFile }.toList()) } + ToolResult.ok(buildJsonObject { + put("local_dir", dest.path) + put("files", r.files) + put("kept_existing", r.kept) + put("bytes", r.bytes) + }.toString()) + } + } + } + ) + dispatcher.register( readTool( name = "ssh.tail_log", diff --git a/core/ssh-tools/src/test/kotlin/com/secrm/core/ssh/tools/McpToolsTest.kt b/core/ssh-tools/src/test/kotlin/com/secrm/core/ssh/tools/McpToolsTest.kt index 522a712..e04330e 100644 --- a/core/ssh-tools/src/test/kotlin/com/secrm/core/ssh/tools/McpToolsTest.kt +++ b/core/ssh-tools/src/test/kotlin/com/secrm/core/ssh/tools/McpToolsTest.kt @@ -30,9 +30,10 @@ class McpToolsTest { name = "box", host = "10.0.0.2", user = "me", hostKey = "1A2B", allowMcpExec = exec, ) - private fun call(vararg conns: SavedConnection, tool: String, args: JsonObject): ToolResult { + private fun call(vararg conns: SavedConnection, tool: String, args: JsonObject, downloads: Boolean = true): ToolResult { val dispatcher = CallDispatcher().also { - SshTools.register(it, FakeSaved(conns.toList()), SshClientManager()) { snippets } + SshTools.register(it, FakeSaved(conns.toList()), SshClientManager(), { snippets }, + downloadDir = if (downloads) ({ java.io.File("unused") }) else null) } return runBlocking { dispatcher.dispatch(Session("t", "test"), tool, args) } } @@ -106,4 +107,26 @@ class McpToolsTest { }) assertEquals("not_permitted", r.errorCode) } + + @Test fun downloadRejectsAnUnsafeFolder() { + for (folder in listOf("../x", "a/b", "..", "")) { + val r = call(box(exec = true), tool = "ssh.download", args = buildJsonObject { + put("connection", "box"); put("remote_path", "/data"); put("folder", folder) + }) + assertTrue("$folder -> ${r.errorCode}", r.errorCode == "invalid_argument" || (folder.isEmpty() && r.errorCode == "not_permitted")) + } + } + + @Test fun downloadNeedsTheReadPermissionAndAFolder() { + val noRead = SavedConnection(name = "box", host = "10.0.0.2", user = "me", hostKey = "1A2B", allowMcpExec = true, allowMcpRead = false) + assertEquals("not_permitted", call(noRead, tool = "ssh.download", args = buildJsonObject { + put("connection", "box"); put("remote_path", "/data") + }).errorCode) + assertEquals("not_available", call(box(exec = true), tool = "ssh.download", downloads = false, args = buildJsonObject { + put("connection", "box"); put("remote_path", "/data") + }).errorCode) + assertEquals("missing_argument", call(box(exec = true), tool = "ssh.download", args = buildJsonObject { + put("connection", "box") + }).errorCode) + } } diff --git a/core/ssh/src/main/kotlin/com/secrm/core/ssh/SftpClient.kt b/core/ssh/src/main/kotlin/com/secrm/core/ssh/SftpClient.kt index d4c8d63..f8fe70d 100644 --- a/core/ssh/src/main/kotlin/com/secrm/core/ssh/SftpClient.kt +++ b/core/ssh/src/main/kotlin/com/secrm/core/ssh/SftpClient.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import java.io.File import java.io.InputStream import java.io.OutputStream @@ -91,6 +92,49 @@ class SftpClient(private val ssh: SshClientManager) { throwIfCancelled(shouldCancel) } + data class TreeResult(val files: Int, val kept: Int, val bytes: Long) + + /** + * Copies [path], a file or a folder with everything under it, into [dest]. Symlinks are skipped, so a link loop + * cannot recurse. A file already in [dest] at the remote size is kept, so a rerun resumes. Over [maxFiles] or + * [maxBytes] it fails before transferring anything. Files are written as `.part` and renamed when complete. + */ + suspend fun downloadTree(path: String, dest: File, maxFiles: Int, maxBytes: Long, shouldCancel: () -> Boolean = { false }): TreeResult = + run { ch -> + val top = ch.lstat(path) + require(!top.isLink) { "$path is a symlink; give the path it points to" } + val name = path.trimEnd('/').substringAfterLast('/') + val plan = ArrayList>() + fun walk(remote: String, local: File) { + for (e in ch.ls(remote).filterIsInstance()) { + val n = e.filename + if (n == "." || n == ".." || '/' in n || '\u0000' in n || e.attrs.isLink) continue + val r = join(remote, n) + if (e.attrs.isDir) walk(r, File(local, n)) + else if (e.attrs.isReg) { + plan += Triple(r, File(local, n), e.attrs.size) + require(plan.size <= maxFiles) { "More than $maxFiles files under $path" } + } + } + } + if (top.isDir) walk(path, dest) else if (top.isReg && name.isNotEmpty() && name != "." && name != "..") plan += Triple(path, File(dest, name), top.size) + else error("$path is not a regular file or folder") + val total = plan.sumOf { it.third } + require(total <= maxBytes) { "$path holds $total bytes, over the $maxBytes limit" } + var kept = 0 + for ((remote, local, size) in plan) { + if (local.length() == size && local.isFile) { kept++; continue } + local.parentFile?.mkdirs() + val part = File(local.path + ".part") + try { + part.outputStream().use { ch.get(remote, it, monitor(shouldCancel) {}) } + throwIfCancelled(shouldCancel) + } catch (t: Throwable) { part.delete(); throw t } + if (!part.renameTo(local)) throw java.io.IOException("Could not save ${local.path}") + } + TreeResult(plan.size, kept, total) + } + // The progress monitor only asks the library to stop; whether that surfaces as an error depends on the // library version. Checking here makes a cancelled transfer always fail, so callers clean up. private fun throwIfCancelled(shouldCancel: () -> Boolean) { diff --git a/core/ssh/src/test/kotlin/com/secrm/core/ssh/SshIntegrationTest.kt b/core/ssh/src/test/kotlin/com/secrm/core/ssh/SshIntegrationTest.kt index f3877b9..f1567da 100644 --- a/core/ssh/src/test/kotlin/com/secrm/core/ssh/SshIntegrationTest.kt +++ b/core/ssh/src/test/kotlin/com/secrm/core/ssh/SshIntegrationTest.kt @@ -165,6 +165,31 @@ class SshIntegrationTest { } } + @Test fun downloadTreeCopiesAFolderSkipsLinksAndResumes() = runBlocking { + val src = Files.createDirectories(root.resolve("route/seg1")) + Files.writeString(root.resolve("route/a.txt"), "aaa") + Files.writeString(src.resolve("b.txt"), "bbbbb") + Files.createSymbolicLink(root.resolve("route/loop"), root.resolve("route")) + connectOrFail() + val sftp = SftpClient(ssh) + val dest = Files.createTempDirectory("dl").toFile() + try { + val home = sftp.home() + val first = sftp.downloadTree(SftpClient.join(home, "route"), dest, maxFiles = 10, maxBytes = 100) + assertEquals(SftpClient.TreeResult(files = 2, kept = 0, bytes = 8), first) + assertEquals("aaa", java.io.File(dest, "a.txt").readText()) + assertEquals("bbbbb", java.io.File(dest, "seg1/b.txt").readText()) + assertFalse(java.io.File(dest, "loop").exists()) + assertEquals(2, sftp.downloadTree(SftpClient.join(home, "route"), dest, 10, 100).kept) + assertThrows(IllegalArgumentException::class.java) { runBlocking { sftp.downloadTree(SftpClient.join(home, "route"), dest, 10, 7) } } + assertThrows(IllegalArgumentException::class.java) { runBlocking { sftp.downloadTree(SftpClient.join(home, "route"), dest, 1, 100) } } + sftp.downloadTree(SftpClient.join(home, "hello.txt"), dest, 1, 100) + assertEquals("hi there", java.io.File(dest, "hello.txt").readText()) + } finally { + sftp.close(); dest.deleteRecursively() + } + } + @Test fun sftpWithoutAConnectionFailsCleanly() { assertThrows(IllegalStateException::class.java) { runBlocking { SftpClient(ssh).home() } } }