tailscale: Add tailssh server
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
+6
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user