tailscale: Add tailssh server

This commit is contained in:
世界
2026-07-06 21:15:25 +08:00
parent d19448c519
commit 33514e54d9
8 changed files with 432 additions and 0 deletions
@@ -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;
}
@@ -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();
}
@@ -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++]
}
@@ -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<LibboxNeighborEntry>) : NeighborEntryIterator {
override fun hasNext(): Boolean = iterator.hasNext()
@@ -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<String>,
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)
@@ -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<out String>?,
term: String?,
rows: Int,
cols: Int,
): IRootShellSession {
val resolved = UserResolver.resolve(packageManager, user!!)
val shell: String
val shellEnv: Array<String>
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<String>
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<out String>?,
shell: String,
home: String,
prefix: String,
term: String?,
): Array<String> {
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<Pair<String, String>>) {
@@ -223,3 +370,46 @@ class RootServer : RootService() {
super.onDestroy()
}
}
internal fun parseEnvArray(sshEnv: Array<out String>?): MutableMap<String, String> {
val env = mutableMapOf<String, String>()
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<out String>?,
shell: String,
home: String,
term: String?,
): Array<String> {
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<String, String>) {
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
}
}
}
@@ -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"
}
}
@@ -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)
}
}