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
10 changes: 7 additions & 3 deletions app/src/main/java/com/secrm/SecureRemoteApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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. */
Expand All @@ -48,6 +55,10 @@ object SshTools {
saved: SavedConnections,
ssh: SshClientManager,
commands: () -> List<NamedCommand> = { 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<File>) -> Unit = {},
): McpSessionPool {
val pool = McpSessionPool(CoroutineScope(Dispatchers.IO + SupervisorJob()), saved) {
// A separate client, so this neither replaces nor drops the app's live session.
Expand Down Expand Up @@ -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/<folder>. 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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
Expand Down Expand Up @@ -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)
}
}
44 changes: 44 additions & 0 deletions core/ssh/src/main/kotlin/com/secrm/core/ssh/SftpClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<Triple<String, File, Long>>()
fun walk(remote: String, local: File) {
for (e in ch.ls(remote).filterIsInstance<ChannelSftp.LsEntry>()) {
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) {
Expand Down
25 changes: 25 additions & 0 deletions core/ssh/src/test/kotlin/com/secrm/core/ssh/SshIntegrationTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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() } }
}
Expand Down
Loading