From 33514e54d96d60e5263a7be8be652f2b2bdc8410 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 28 May 2026 22:42:40 +0800 Subject: [PATCH] tailscale: Add tailssh server --- .../io/nekohasekai/sfa/bg/IRootService.aidl | 5 + .../nekohasekai/sfa/bg/IRootShellSession.aidl | 11 + .../io/nekohasekai/sfa/bg/IntArrayIterator.kt | 13 ++ .../sfa/bg/PlatformInterfaceWrapper.kt | 122 +++++++++++ .../java/io/nekohasekai/sfa/bg/RootClient.kt | 25 +++ .../java/io/nekohasekai/sfa/bg/RootServer.kt | 190 ++++++++++++++++++ .../io/nekohasekai/sfa/bg/UserResolver.kt | 60 ++++++ .../tools/TailscaleSSHTerminalScreen.kt | 6 + 8 files changed, 432 insertions(+) create mode 100644 app/src/main/aidl/io/nekohasekai/sfa/bg/IRootShellSession.aidl create mode 100644 app/src/main/java/io/nekohasekai/sfa/bg/IntArrayIterator.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/bg/UserResolver.kt diff --git a/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl b/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl index 382c192..ad0425a 100644 --- a/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl +++ b/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl @@ -2,6 +2,7 @@ package io.nekohasekai.sfa.bg; import android.os.ParcelFileDescriptor; import io.nekohasekai.sfa.bg.INeighborTableCallback; +import io.nekohasekai.sfa.bg.IRootShellSession; import io.nekohasekai.sfa.bg.ParceledListSlice; interface IRootService { @@ -16,4 +17,8 @@ interface IRootService { void registerNeighborTableCallback(in INeighborTableCallback callback) = 4; oneway void unregisterNeighborTableCallback(in INeighborTableCallback callback) = 5; + + IRootShellSession openShellSession(String user, String command, in String[] env, String term, int rows, int cols) = 6; + + String lookupSFTPServer() = 7; } diff --git a/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootShellSession.aidl b/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootShellSession.aidl new file mode 100644 index 0000000..2c8eaa0 --- /dev/null +++ b/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootShellSession.aidl @@ -0,0 +1,11 @@ +package io.nekohasekai.sfa.bg; + +import android.os.ParcelFileDescriptor; + +interface IRootShellSession { + ParcelFileDescriptor getMasterFD(); + void resize(int rows, int cols); + void signal(int sig); + int waitFor(); + void close(); +} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/IntArrayIterator.kt b/app/src/main/java/io/nekohasekai/sfa/bg/IntArrayIterator.kt new file mode 100644 index 0000000..f8d7abf --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/IntArrayIterator.kt @@ -0,0 +1,13 @@ +package io.nekohasekai.sfa.bg + +import io.nekohasekai.libbox.Int32Iterator + +class IntArrayIterator(private val array: IntArray) : Int32Iterator { + private var index = 0 + + override fun len(): Int = array.size + + override fun hasNext(): Boolean = index < array.size + + override fun next(): Int = array[index++] +} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt b/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt index 78b3888..07c0776 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt @@ -3,6 +3,7 @@ package io.nekohasekai.sfa.bg import android.annotation.SuppressLint import android.net.NetworkCapabilities import android.os.Build +import android.os.ParcelFileDescriptor import android.os.Process import android.system.OsConstants import android.util.Log @@ -15,12 +16,17 @@ import io.nekohasekai.libbox.NeighborEntryIterator import io.nekohasekai.libbox.NeighborUpdateListener import io.nekohasekai.libbox.NetworkInterfaceIterator import io.nekohasekai.libbox.PlatformInterface +import io.nekohasekai.libbox.PlatformUser +import io.nekohasekai.libbox.ShellSession import io.nekohasekai.libbox.StringIterator import io.nekohasekai.libbox.TunOptions import io.nekohasekai.libbox.WIFIState import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.ktx.toList +import io.nekohasekai.sfa.ktx.toStringIterator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking +import java.io.File import java.net.Inet6Address import java.net.InetSocketAddress import java.net.InterfaceAddress @@ -205,6 +211,99 @@ interface PlatformInterfaceWrapper : PlatformInterface { } } + override fun usePlatformShell(): Boolean = true + + override fun checkPlatformShell() { + val available = RootClient.rootAvailable.value ?: runBlocking(Dispatchers.IO) { + RootClient.checkRootAvailable() + } + if (!available) { + error("missing root permission") + } + } + + override fun openShellSession( + user: PlatformUser?, + command: String?, + environ: StringIterator?, + term: String?, + rows: Int, + cols: Int, + ): ShellSession { + user!! + val envList = environ?.toList().orEmpty() + if (user.uid == Process.myUid()) { + val resolved = ResolvedUser(user.username, user.uid, user.gid, user.homeDir) + val shell = UserResolver.findShell(resolved) + val shellEnv = buildBasicEnvironment(envList.toTypedArray(), shell, resolved.homeDir, term) + val args = if (command.isNullOrEmpty()) { + arrayOf("-" + File(shell).name) + } else { + arrayOf(File(shell).name, "-c", command) + } + val argsIter = args.asIterable().toStringIterator() + val envIter = shellEnv.asIterable().toStringIterator() + return if (term.isNullOrEmpty()) { + Libbox.openNativePipeSession( + shell, + resolved.homeDir, + argsIter, + envIter, + -1, + -1, + null, + ) + } else { + Libbox.openNativeShellSession( + shell, + resolved.homeDir, + argsIter, + envIter, + term, + rows, + cols, + -1, + -1, + null, + ) + } + } + val rootSession = runBlocking(Dispatchers.IO) { + RootClient.openShellSession( + user.username, + command, + envList.toTypedArray(), + term, + rows, + cols, + ) + } + return RootShellSessionWrapper(rootSession) + } + + override fun readSystemSSHHostKey(): io.nekohasekai.libbox.StringBox { + error("not supported") + } + + override fun lookupSFTPServer(): io.nekohasekai.libbox.StringBox { + val path = runBlocking(Dispatchers.IO) { + RootClient.lookupSFTPServer() + } + val result = io.nekohasekai.libbox.StringBox() + result.value = path + return result + } + + override fun lookupUser(username: String?): io.nekohasekai.libbox.PlatformUser { + val resolved = UserResolver.resolve(Application.packageManager, username!!) + val platformUser = io.nekohasekai.libbox.PlatformUser() + platformUser.username = resolved.packageName + platformUser.uid = resolved.uid + platformUser.gid = resolved.gid + platformUser.homeDir = resolved.homeDir + return platformUser + } + override fun registerMyInterface(name: String?) { } @@ -216,6 +315,29 @@ interface PlatformInterfaceWrapper : PlatformInterface { } } + private class RootShellSessionWrapper( + private val rootSession: IRootShellSession, + ) : ShellSession { + private val masterPfd: ParcelFileDescriptor = rootSession.masterFD + + override fun masterFD(): Int = masterPfd.fd + + override fun resize(rows: Int, cols: Int) { + rootSession.resize(rows, cols) + } + + override fun signal(signal: Int) { + rootSession.signal(signal) + } + + override fun waitExit(): Int = rootSession.waitFor() + + override fun close() { + masterPfd.close() + rootSession.close() + } + } + private class NeighborEntryArray(private val iterator: Iterator) : NeighborEntryIterator { override fun hasNext(): Boolean = iterator.hasNext() diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt b/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt index 66c3e7d..fe2977d 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt @@ -144,6 +144,31 @@ object RootClient { } } + suspend fun lookupSFTPServer(): String { + val svc = bindService() + try { + return svc.lookupSFTPServer() + } catch (e: RemoteException) { + throw e.rethrowAsRuntime() + } + } + + suspend fun openShellSession( + user: String, + command: String?, + env: Array, + term: String?, + rows: Int, + cols: Int, + ): IRootShellSession { + val svc = bindService() + try { + return svc.openShellSession(user, command, env, term, rows, cols) + } catch (e: RemoteException) { + throw e.rethrowAsRuntime() + } + } + suspend fun unregisterNeighborTableCallback(callback: INeighborTableCallback) { try { service?.unregisterNeighborTableCallback(callback) diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt b/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt index 13b0bcc..38eaf8d 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt @@ -2,6 +2,7 @@ package io.nekohasekai.sfa.bg import android.content.Intent import android.content.pm.PackageInfo +import android.content.pm.PackageManager import android.os.Build import android.os.IBinder import android.os.ParcelFileDescriptor @@ -12,8 +13,11 @@ import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.NeighborEntryIterator import io.nekohasekai.libbox.NeighborSubscription import io.nekohasekai.libbox.NeighborUpdateListener +import io.nekohasekai.libbox.ShellSession import io.nekohasekai.sfa.BuildConfig +import io.nekohasekai.sfa.ktx.toStringIterator import io.nekohasekai.sfa.vendor.PrivilegedServiceUtils +import java.io.File import java.io.IOException import java.lang.reflect.Proxy import java.util.concurrent.ConcurrentHashMap @@ -91,6 +95,149 @@ class RootServer : RootService() { } } } + + override fun openShellSession( + user: String?, + command: String?, + env: Array?, + term: String?, + rows: Int, + cols: Int, + ): IRootShellSession { + val resolved = UserResolver.resolve(packageManager, user!!) + val shell: String + val shellEnv: Array + val cwd: String + if (resolved.packageName == UserResolver.TERMUX_PACKAGE) { + val termuxPrefix = File(UserResolver.TERMUX_PREFIX) + val actualShell = UserResolver.findTermuxShell(termuxPrefix, resolved.homeDir) + cwd = resolved.homeDir + shellEnv = + buildTermuxEnvironment(env, actualShell, cwd, termuxPrefix.absolutePath, term) + shell = if (command.isNullOrEmpty()) { + val loginBin = File(termuxPrefix, "bin/login") + if (loginBin.canExecute()) loginBin.absolutePath else actualShell + } else { + actualShell + } + } else if (resolved.uid == 0) { + val termuxPrefix = File(UserResolver.TERMUX_PREFIX) + val termuxAvailable = File(termuxPrefix, "bin").isDirectory + if (termuxAvailable) { + shell = UserResolver.findTermuxShell(termuxPrefix, UserResolver.TERMUX_HOME) + cwd = UserResolver.TERMUX_HOME + shellEnv = buildTermuxEnvironment( + env, + shell, + cwd, + termuxPrefix.absolutePath, + term, + ) + } else { + shell = "/system/bin/sh" + cwd = "/data/local" + shellEnv = buildBasicEnvironment(env, shell, cwd, term) + } + } else { + shell = UserResolver.findShell(resolved) + cwd = resolved.homeDir + shellEnv = buildBasicEnvironment(env, shell, cwd, term) + } + val args: Array + if (command.isNullOrEmpty()) { + args = arrayOf("-" + File(shell).name) + } else { + args = arrayOf(File(shell).name, "-c", command) + } + val supplementaryGids = if (resolved.packageName == "root" || resolved.packageName == "shell") { + intArrayOf() + } else { + packageManager.getPackageGids(resolved.packageName) + } + val argsIter = args.asIterable().toStringIterator() + val envIter = shellEnv.asIterable().toStringIterator() + val groupsIter = IntArrayIterator(supplementaryGids) + val isPipe = term.isNullOrEmpty() + val session = if (isPipe) { + Libbox.openNativePipeSession( + shell, + cwd, + argsIter, + envIter, + resolved.uid, + resolved.gid, + groupsIter, + ) + } else { + Libbox.openNativeShellSession( + shell, cwd, argsIter, envIter, + term, rows, cols, + resolved.uid, resolved.gid, groupsIter, + ) + } + return RootShellSession(session) + } + + override fun lookupSFTPServer(): String { + val termuxPrefix = File(UserResolver.TERMUX_PREFIX) + for (name in arrayOf("libexec/sftp-server", "lib/openssh/sftp-server")) { + val candidate = File(termuxPrefix, name) + if (candidate.canExecute()) return candidate.absolutePath + } + throw IOException("sftp-server not found, install openssh in Termux") + } + } + + private fun buildTermuxEnvironment( + sshEnv: Array?, + shell: String, + home: String, + prefix: String, + term: String?, + ): Array { + val env = parseEnvArray(sshEnv) + env["HOME"] = home + env["PREFIX"] = prefix + env["PATH"] = "$prefix/bin" + env["TMPDIR"] = "$prefix/tmp" + env["SHELL"] = shell + env["LANG"] = "en_US.UTF-8" + env["COLORTERM"] = "truecolor" + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + env["LD_LIBRARY_PATH"] = "$prefix/lib" + } else { + env.remove("LD_LIBRARY_PATH") + } + val termuxExec = File("$prefix/lib/libtermux-exec.so") + if (termuxExec.exists()) { + env["LD_PRELOAD"] = termuxExec.absolutePath + } + if (!term.isNullOrEmpty()) { + env["TERM"] = term + } + addAndroidSystemEnvironment(env) + return env.map { (k, v) -> "$k=$v" }.toTypedArray() + } + + private class RootShellSession( + private val session: ShellSession, + ) : IRootShellSession.Stub() { + + override fun getMasterFD(): ParcelFileDescriptor = ParcelFileDescriptor.fromFd(session.masterFD()) + + override fun resize(rows: Int, cols: Int) { + session.resize(rows, cols) + } + + override fun signal(sig: Int) { + session.signal(sig) + } + + override fun waitFor(): Int = session.waitExit() + + override fun close() { + session.close() + } } private fun broadcastEnrichedEntries(rawList: List>) { @@ -223,3 +370,46 @@ class RootServer : RootService() { super.onDestroy() } } + +internal fun parseEnvArray(sshEnv: Array?): MutableMap { + val env = mutableMapOf() + sshEnv?.forEach { entry -> + val idx = entry.indexOf('=') + if (idx > 0) env[entry.substring(0, idx)] = entry.substring(idx + 1) + } + return env +} + +internal fun buildBasicEnvironment( + sshEnv: Array?, + shell: String, + home: String, + term: String?, +): Array { + val env = parseEnvArray(sshEnv) + env["HOME"] = home + env["PATH"] = "/system/bin:/system/xbin:/vendor/bin" + env["SHELL"] = shell + env["TMPDIR"] = "/data/local/tmp" + if (!term.isNullOrEmpty()) { + env["TERM"] = term + } + addAndroidSystemEnvironment(env) + return env.map { (k, v) -> "$k=$v" }.toTypedArray() +} + +internal fun addAndroidSystemEnvironment(env: MutableMap) { + val androidVars = arrayOf( + "ANDROID_ASSETS", "ANDROID_DATA", "ANDROID_ROOT", "ANDROID_STORAGE", + "EXTERNAL_STORAGE", "ASEC_MOUNTPOINT", "LOOP_MOUNTPOINT", + "ANDROID_RUNTIME_ROOT", "ANDROID_ART_ROOT", + "ANDROID_I18N_ROOT", "ANDROID_TZDATA_ROOT", + "BOOTCLASSPATH", "DEX2OATBOOTCLASSPATH", "SYSTEMSERVERCLASSPATH", + ) + for (name in androidVars) { + val value = System.getenv(name) + if (value != null) { + env[name] = value + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/UserResolver.kt b/app/src/main/java/io/nekohasekai/sfa/bg/UserResolver.kt new file mode 100644 index 0000000..2e839eb --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/UserResolver.kt @@ -0,0 +1,60 @@ +package io.nekohasekai.sfa.bg + +import android.content.pm.PackageManager +import android.os.Process +import io.nekohasekai.sfa.BuildConfig +import java.io.File + +data class ResolvedUser( + val packageName: String, + val uid: Int, + val gid: Int, + val homeDir: String, +) + +object UserResolver { + + const val TERMUX_PACKAGE = "com.termux" + const val TERMUX_PREFIX = "/data/data/com.termux/files/usr" + const val TERMUX_HOME = "/data/data/com.termux/files/home" + + fun resolve(pm: PackageManager, username: String): ResolvedUser = when (username) { + "root" -> ResolvedUser("root", Process.ROOT_UID, Process.ROOT_UID, "/") + "shell" -> ResolvedUser("shell", Process.SHELL_UID, Process.SHELL_UID, "/data/local") + "termux" -> resolvePackage(pm, TERMUX_PACKAGE) + "sing-box" -> resolvePackage(pm, BuildConfig.APPLICATION_ID) + else -> resolvePackage(pm, username) + } + + private fun resolvePackage(pm: PackageManager, packageName: String): ResolvedUser { + val appInfo = pm.getApplicationInfo(packageName, 0) + val homeDir = when (packageName) { + TERMUX_PACKAGE -> TERMUX_HOME + else -> appInfo.dataDir + } + return ResolvedUser(packageName, appInfo.uid, appInfo.uid, homeDir) + } + + fun findShell(resolved: ResolvedUser): String { + if (resolved.packageName == TERMUX_PACKAGE) { + return findTermuxShell( + File(TERMUX_PREFIX), + resolved.homeDir, + ) + } + return "/system/bin/sh" + } + + fun findTermuxShell(prefix: File, homeDir: String): String { + val dotTermuxShell = File(homeDir, ".termux/shell") + if (dotTermuxShell.canExecute()) { + return dotTermuxShell.canonicalPath + } + val binDir = File(prefix, "bin") + for (name in arrayOf("bash", "zsh", "fish", "sh")) { + val candidate = File(binDir, name) + if (candidate.canExecute()) return candidate.absolutePath + } + return "/system/bin/sh" + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleSSHTerminalScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleSSHTerminalScreen.kt index 22c3ab2..062f3a9 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleSSHTerminalScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleSSHTerminalScreen.kt @@ -354,6 +354,12 @@ private fun createSessionClient(viewModel: TailscaleSSHTerminalViewModel, viewRe if (state.sessions.size > 1) { val managed = state.sessions.firstOrNull { it.terminalSession === finishedSession } if (managed != null) { + if (managed.id == state.activeSessionId) { + val sshSession = finishedSession as TailscaleSSHTerminalSession + if (sshSession.getSSHExitCode() != 0) { + return + } + } viewModel.removeSession(managed.id) } }