diff --git a/.gitignore b/.gitignore index f2ec659f99..eba5150c75 100644 --- a/.gitignore +++ b/.gitignore @@ -38,7 +38,11 @@ build/ *.iml # Compiled JNI libraries folder -**/jniLibs +**/jniLibs/* +!app/src/main/jniLibs/x86_64/ +!app/src/main/jniLibs/x86_64/libeasytier_android_jni.so +!app/src/main/jniLibs/x86_64/libeasytier_ffi.so +!app/src/main/jniLibs/x86_64/README.md app/.externalNativeBuild/ # NDK stuff diff --git a/app/build.gradle b/app/build.gradle index f44c3b9c93..c7d7592977 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -22,6 +22,7 @@ def escapeBuildConfigString = { value -> value.replace("\\", "\\\\").replace("\"", "\\\"") } def enableX86TestAbi = (project.findProperty("enableX86TestAbi") ?: "false").toString().toBoolean() +def enableX8664TestAbi = (project.findProperty("enableX8664TestAbi") ?: "false").toString().toBoolean() def audioHapticsAppLabel = "Moonlight V+" def audioHapticsSdkDir = ( project.findProperty("audioHapticsSdkDir") @@ -81,6 +82,9 @@ android { if (enableX86TestAbi) { supportedAbis += 'x86' } + if (enableX8664TestAbi) { + supportedAbis += 'x86_64' + } abiFilters(*supportedAbis) } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4b70fbd9b1..c0c0444815 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -180,6 +180,7 @@ + + + + + + + + ) { - val intent = Intent(activity, EasyTierVpnService::class.java) + val intent = Intent(appContext, EasyTierVpnService::class.java) intent.putExtra("ipv4_address", ipv4) intent.putStringArrayListExtra("proxy_cidrs", ArrayList(proxyCidrs)) + intent.putExtra("allowed_remote_host", allowedRemoteHost) intent.putExtra("instance_name", instanceName) - activity.startService(intent) + appContext.startService(intent) vpnServiceIntent = intent } private fun stopVpnService() { val stopIntent = Intent(EasyTierVpnService.ACTION_STOP_VPN) - activity.sendBroadcast(stopIntent) + stopIntent.setPackage(appContext.packageName) + appContext.sendBroadcast(stopIntent) Log.i(TAG, "停止发送VPN广播。") vpnServiceIntent = null } @@ -218,3 +222,25 @@ class EasyTierManager( private const val MONITOR_INTERVAL = 3000L } } + +/** Keeps the active mesh alive across activity recreation and streaming UI transitions. */ +object EasyTierRuntime { + @Volatile + private var manager: EasyTierManager? = null + + @Synchronized + fun getOrCreate( + context: Context, + instanceName: String, + networkConfig: String, + allowedRemoteHost: String? = null + ): EasyTierManager { + val current = manager + if (current != null && + current.networkConfig == networkConfig && + current.allowedRemoteHost == allowedRemoteHost + ) return current + current?.stop() + return EasyTierManager(context, instanceName, networkConfig, allowedRemoteHost).also { manager = it } + } +} diff --git a/app/src/main/java/com/easytier/jni/EasyTierVpnService.kt b/app/src/main/java/com/easytier/jni/EasyTierVpnService.kt index 1301d6f188..dffe4f3822 100644 --- a/app/src/main/java/com/easytier/jni/EasyTierVpnService.kt +++ b/app/src/main/java/com/easytier/jni/EasyTierVpnService.kt @@ -53,6 +53,7 @@ class EasyTierVpnService : VpnService() { try { val ipv4Address = intent.getStringExtra("ipv4_address") val proxyCidrs = intent.getStringArrayListExtra("proxy_cidrs") + val allowedRemoteHost = intent.getStringExtra("allowed_remote_host") instanceName = intent.getStringExtra("instance_name") if (ipv4Address == null || instanceName == null) { @@ -60,7 +61,7 @@ class EasyTierVpnService : VpnService() { return@Thread } - setupVpnInterface(ipv4Address, proxyCidrs ?: ArrayList()) + setupVpnInterface(ipv4Address, proxyCidrs ?: ArrayList(), allowedRemoteHost) } catch (t: Throwable) { Log.e(TAG, "VPN设置线程失败", t) cleanupAndStop() @@ -71,25 +72,38 @@ class EasyTierVpnService : VpnService() { return START_NOT_STICKY } - private fun setupVpnInterface(ipv4Address: String, proxyCidrs: List) { + private fun setupVpnInterface( + ipv4Address: String, + proxyCidrs: List, + allowedRemoteHost: String? + ) { try { val addressInfo = parseIpv4Address(ipv4Address) - val builder = Builder() - builder.setSession("EasyTier VPN") - .addAddress(addressInfo.ip, addressInfo.networkLength) - .addDnsServer("223.5.5.5") - - try { - builder.addAddress("fd00::1", 128) - Log.i(TAG, "已激活 VPN 接口 IPv6 协议栈 (fd00::1/128) 以支持双栈通信") - } catch (e: Exception) { - Log.w(TAG, "添加 IPv6 地址失败", e) + val builder = Builder().setSession("EasyTier VPN") + + if (allowedRemoteHost != null) { + // A host-issued profile may reach exactly one overlay host and nothing else. + requireValidIpv4(allowedRemoteHost) + builder.addAddress(addressInfo.ip, 32) + .addRoute(allowedRemoteHost, 32) + Log.i(TAG, "为主机签发配置添加唯一允许路由:$allowedRemoteHost/32") + } else { + builder.addAddress(addressInfo.ip, addressInfo.networkLength) + .addRoute(networkAddress(addressInfo.ip, addressInfo.networkLength), addressInfo.networkLength) + Log.i(TAG, "为虚拟网络添加了VPN路由:${addressInfo.ip}/${addressInfo.networkLength}") } - Log.i(TAG, "为虚拟网络添加了VPN路由:${addressInfo.ip}/${addressInfo.networkLength}") + if (allowedRemoteHost == null) { + try { + builder.addAddress("fd00::1", 128) + Log.i(TAG, "已激活 VPN 接口 IPv6 协议栈 (fd00::1/128) 以支持双栈通信") + } catch (e: Exception) { + Log.w(TAG, "添加 IPv6 地址失败", e) + } + } - for (cidr in proxyCidrs) { + for (cidr in if (allowedRemoteHost == null) proxyCidrs else emptyList()) { Log.i(TAG, "为虚拟网络添加代理CIDR:$cidr") try { val routeInfo = parseCidr(cidr) @@ -162,6 +176,23 @@ class EasyTierVpnService : VpnService() { return IpAddressInfo(parts[0], parts[1].toInt()) } + private fun networkAddress(ip: String, prefixLength: Int): String { + require(prefixLength in 0..32) { "Invalid IPv4 prefix length" } + val value = ip.split('.').fold(0L) { result, octet -> + val parsed = octet.toInt() + require(parsed in 0..255) { "Invalid IPv4 address" } + (result shl 8) or parsed.toLong() + } + val mask = if (prefixLength == 0) 0L else (0xffffffffL shl (32 - prefixLength)) and 0xffffffffL + val network = value and mask + return "${(network shr 24) and 0xff}.${(network shr 16) and 0xff}.${(network shr 8) and 0xff}.${network and 0xff}" + } + + private fun requireValidIpv4(ip: String) { + require(ip.split('.').size == 4) { "Invalid IPv4 address" } + ip.split('.').forEach { octet -> require(octet.toIntOrNull() in 0..255) { "Invalid IPv4 address" } } + } + companion object { private const val TAG = "EasyTierVpnService" const val ACTION_STOP_VPN = "com.easytier.jni.ACTION_STOP_VPN" diff --git a/app/src/main/java/com/limelight/PcView.kt b/app/src/main/java/com/limelight/PcView.kt index 92ef865e6d..4c99295356 100644 --- a/app/src/main/java/com/limelight/PcView.kt +++ b/app/src/main/java/com/limelight/PcView.kt @@ -6,6 +6,8 @@ import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.IOException import java.io.StringReader +import java.net.InetSocketAddress +import java.net.Socket import java.net.UnknownHostException import java.text.SimpleDateFormat import java.util.Date @@ -63,6 +65,9 @@ import com.limelight.utils.CacheHelper import com.limelight.utils.ConfigurationSyncScheduler import com.limelight.utils.Dialog import com.limelight.utils.easytier.EasyTierController +import com.limelight.utils.remoteconnect.RemoteConnectCode +import com.limelight.utils.remoteconnect.RemoteConnectCodeParser +import com.limelight.utils.remoteconnect.PendingRemoteConnectState import com.limelight.utils.HelpLauncher import com.limelight.utils.Iperf3Tester import com.limelight.utils.NetHelper @@ -213,6 +218,7 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas private const val NETWORK_QUALITY_ID = 18 private const val ADD_PC_MANUALLY_ID = 19 private const val ADD_PC_QR_SCAN_ID = 20 + private const val REMOTE_HOST_CONNECT_TIMEOUT_MS = 30_000L } @@ -236,6 +242,8 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas private var lastShakeTime = 0L private var activeSceneNumber: Int? = null private var pendingAddedComputerUuid: String? = null + private var pendingRemoteConnectionCode: RemoteConnectCode? = null + private var pendingConnectionIntentUrl: String? = null private val exitGate = PcViewExitGate() // Helpers @@ -283,6 +291,7 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas // 与网络往返重叠,最坏情况持平、最佳情况节省整段证书时间。 managerBinder = localBinder showPendingAddedComputer() + consumePendingConnectionIntent() startComputerUpdates() // 后台预热:等 DiscoveryService bind(mDNS 可能还没好),并把客户端证书 @@ -370,6 +379,7 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas } easyTierController = EasyTierController(this, this) + captureConnectionIntent(intent) inForeground = true val glPrefs = GlPreferences.readPreferences(this) @@ -389,6 +399,12 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas AboutDialogLauncher.onConfigurationChanged(this, newConfig) } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + captureConnectionIntent(intent) + } + override fun onResume() { super.onResume() UiHelper.showDecoderCrashDialog(this) @@ -455,6 +471,10 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas override fun onDestroy() { AboutDialogLauncher.release(this) + if (isFinishing) { + pendingRemoteConnectionCode = null + PendingRemoteConnectState.consume() + } super.onDestroy() uiScope.cancel() @@ -2119,29 +2139,81 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas integrator.initiateScan() } - private fun handleQrPairResult(url: String) { - val uri = url.toUri() - if ("moonlight" != uri.scheme || "pair" != uri.host) { - showToast(getString(R.string.qr_invalid_code)) + private fun captureConnectionIntent(intent: Intent?) { + val uri = intent?.data ?: return + if (intent.action != Intent.ACTION_VIEW || + !uri.scheme.equals("moonlight", ignoreCase = true) || + !uri.host.equals("pair", ignoreCase = true) + ) { return } - val host = uri.getQueryParameter("host") - val portStr = uri.getQueryParameter("port") - val pin = uri.getQueryParameter("pin") + pendingConnectionIntentUrl = uri.toString() + consumePendingConnectionIntent() + } + + private fun consumePendingConnectionIntent() { + if (!completeOnCreateCalled || managerBinder == null || easyTierController == null) return + val url = pendingConnectionIntentUrl ?: return + pendingConnectionIntentUrl = null + handleQrPairResult(url, requireConfirmation = true) + } - if (host == null || pin == null) { + private fun handleQrPairResult(url: String, requireConfirmation: Boolean = false) { + val code = try { + RemoteConnectCodeParser.parse(url) + } catch (_: IllegalArgumentException) { showToast(getString(R.string.qr_invalid_code)) return } - var port = NvHTTP.DEFAULT_HTTP_PORT - if (portStr != null) { - try { port = portStr.toInt() } catch (ignored: NumberFormatException) {} + if (requireConfirmation) { + showExternalConnectionConfirmation(code) + return + } + + activateConnectionCode(code) + } + + private fun showExternalConnectionConfirmation(code: RemoteConnectCode) { + val label = code.name?.takeIf { it.isNotBlank() } ?: code.host + val message = if (code.easyTierProfile != null) { + getString(R.string.remote_connect_external_confirm_message, label, code.host) + } else { + getString(R.string.qr_pair_external_confirm_message, label, code.host) + } + val dialog = AlertDialog.Builder(this, R.style.AppDialogStyle) + .setTitle(R.string.remote_connect_external_confirm_title) + .setMessage(message) + .setPositiveButton(R.string.remote_connect_external_confirm_action) { _, _ -> + activateConnectionCode(code) + } + .setNegativeButton(android.R.string.cancel, null) + .create() + dialog.show() + AppDialogStyler.apply(dialog, this) + } + + private fun activateConnectionCode(code: RemoteConnectCode) { + if (code.easyTierProfile != null) { + pendingRemoteConnectionCode = code + PendingRemoteConnectState.stage(code) + showToast(getString(R.string.remote_connect_preparing)) + try { + easyTierController?.activateConnectionProfile(code.easyTierProfile) + ?: throw IllegalStateException("EasyTier controller unavailable") + } catch (_: Exception) { + pendingRemoteConnectionCode = null + PendingRemoteConnectState.consume() + showToast(getString(R.string.remote_connect_setup_failed)) + } + } else { + pairFromConnectionCode(code, waitForRemoteHost = false) } + } + private fun pairFromConnectionCode(code: RemoteConnectCode, waitForRemoteHost: Boolean) { showToast(getString(R.string.qr_pairing)) - val finalPort = port uiScope.launch { var message: String? var success = false @@ -2150,10 +2222,16 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas try { stopComputerUpdatesAndWait() + if (waitForRemoteHost && !waitForHostEndpoint(code.host, code.port)) { + showToast(getString(R.string.remote_connect_host_timeout)) + startComputerUpdates() + return@launch + } + val result = withContext(Dispatchers.IO) { // Add the computer first val addDetails = ComputerDetails() - addDetails.manualAddress = ComputerDetails.AddressTuple(host, finalPort) + addDetails.manualAddress = ComputerDetails.AddressTuple(code.host, code.port) val added = managerBinder?.addComputerBlocking(addDetails) == true if (!added) { return@withContext QrPairResult(getString(R.string.addpc_fail), false, null, null) @@ -2179,7 +2257,7 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas } val pm = httpConn.pairingManager - val pairResult = pm.pair(httpConn.getServerInfo(true), pin) + val pairResult = pm.pair(httpConn.getServerInfo(true), code.pin) when (pairResult.state) { PairState.PIN_WRONG -> QrPairResult(getString(R.string.pair_incorrect_pin), false, null, null) @@ -2227,6 +2305,22 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas } } + private suspend fun waitForHostEndpoint(host: String, port: Int): Boolean = + withContext(Dispatchers.IO) { + val deadline = SystemClock.elapsedRealtime() + REMOTE_HOST_CONNECT_TIMEOUT_MS + while (SystemClock.elapsedRealtime() < deadline) { + try { + Socket().use { socket -> + socket.connect(InetSocketAddress(host, port), 1_000) + return@withContext true + } + } catch (_: IOException) { + Thread.sleep(500) + } + } + false + } + private data class QrPairResult( val message: String?, val success: Boolean, @@ -3335,7 +3429,7 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas val scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, data) if (scanResult != null) { if (scanResult.contents != null) { - handleQrPairResult(scanResult.contents.trim()) + handleQrPairResult(scanResult.contents.trim(), requireConfirmation = false) } return } @@ -3353,6 +3447,12 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas super.onActivityResult(requestCode, resultCode, data) if (requestCode == VPN_PERMISSION_REQUEST_CODE && easyTierController != null) { easyTierController?.handleVpnPermissionResult(resultCode) + val retainedCode = PendingRemoteConnectState.consume() + val pendingCode = pendingRemoteConnectionCode ?: retainedCode + pendingRemoteConnectionCode = null + if (resultCode == RESULT_OK && pendingCode != null) { + pairFromConnectionCode(pendingCode, waitForRemoteHost = true) + } } else if (requestCode == UpdateManager.INSTALL_PERMISSION_REQUEST_CODE) { UpdateManager.onInstallPermissionResult(this) } diff --git a/app/src/main/java/com/limelight/utils/easytier/EasyTierController.kt b/app/src/main/java/com/limelight/utils/easytier/EasyTierController.kt index 0018fc4491..1663fb240f 100644 --- a/app/src/main/java/com/limelight/utils/easytier/EasyTierController.kt +++ b/app/src/main/java/com/limelight/utils/easytier/EasyTierController.kt @@ -63,10 +63,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.view.doOnLayout import com.easytier.jni.EasyTierManager +import com.easytier.jni.EasyTierRuntime import com.limelight.LimeLog import com.limelight.R import com.limelight.ui.theme.AppShapes import com.limelight.utils.AppDialogStyler +import com.limelight.utils.remoteconnect.EasyTierConnectionProfile +import com.limelight.utils.remoteconnect.PendingRemoteConnectState import org.json.JSONArray import org.json.JSONObject @@ -92,6 +95,7 @@ class EasyTierController( ) { private var easyTierManager: EasyTierManager? = null private var currentDialog: Dialog? = null + private var pendingConnectionProfile: EasyTierConnectionProfile? = null private val instanceName = "Default" private enum class EasyTierTab { @@ -130,18 +134,26 @@ class EasyTierController( // ==================== 初始化和生命周期 ==================== private fun initEasyTierManager() { + val prefs = activity.getSharedPreferences(EASYTIER_PREFS, Context.MODE_PRIVATE) val config = getEasyTierConfig() + val activeProfile = prefs.getString(KEY_ACTIVE_PROFILE, null) + val allowedRemoteHost = activeProfile?.let { prefs.getString(KEY_PROFILE_HOST_PREFIX + it, null) } - if (easyTierManager != null && easyTierManager?.latestNetworkInfoJson != null) { - easyTierManager?.stop() - } - LimeLog.info("使用的easytier配置为:\n$config") - easyTierManager = EasyTierManager(activity, instanceName, config) + val redactedConfig = config.replace( + Regex("(?m)^(network_secret\\s*=\\s*)\".*\"$"), + "$1\"\"" + ) + LimeLog.info("使用的easytier配置为:\n$redactedConfig") + easyTierManager = EasyTierRuntime.getOrCreate( + activity.applicationContext, + instanceName, + config, + allowedRemoteHost + ) LimeLog.info("$TAG: EasyTierManager initialized with instance: $instanceName") } fun onDestroy() { - easyTierManager?.stop() if (currentDialog != null && currentDialog?.isShowing == true) { currentDialog?.dismiss() } @@ -161,14 +173,44 @@ class EasyTierController( fun handleVpnPermissionResult(resultCode: Int) { if (resultCode == Activity.RESULT_OK) { LimeLog.info("$TAG: VPN权限已获取,启动EasyTier Manager。") + val approvedProfile = pendingConnectionProfile + ?: PendingRemoteConnectState.peek()?.easyTierProfile + approvedProfile?.let { profile -> + persistConnectionProfile(profile) + initEasyTierManager() + } + pendingConnectionProfile = null easyTierManager?.start() Toast.makeText(activity, R.string.easytier_starting, Toast.LENGTH_SHORT).show() } else { + pendingConnectionProfile = null LimeLog.warning("$TAG: VPN权限被拒绝。") Toast.makeText(activity, R.string.easytier_vpn_permission_required, Toast.LENGTH_LONG).show() } } + /** + * Stages a host-issued remote connection profile and starts the VPN permission flow. + * The structured profile is converted to an allow-listed TOML configuration here; + * connection codes are never allowed to inject raw EasyTier configuration. The profile + * is not persisted or selected until the user grants VPN permission. + */ + internal fun activateConnectionProfile(profile: EasyTierConnectionProfile) { + pendingConnectionProfile = profile + vpnCallback.requestVpnPermission() + } + + private fun persistConnectionProfile(profile: EasyTierConnectionProfile) { + val toml = EasyTierTomlCodec.buildConnectionProfile(profile) + activity.getSharedPreferences(EASYTIER_PREFS, Context.MODE_PRIVATE) + .edit() + .putString(KEY_TOML_CONFIG, toml) + .putString(KEY_ACTIVE_PROFILE, profile.id) + .putString(KEY_PROFILE_PREFIX + profile.id, toml) + .putString(KEY_PROFILE_HOST_PREFIX + profile.id, profile.hostVirtualIp) + .apply() + } + // ==================== 对话框管理 ==================== private fun createAndShowDialog() { @@ -743,7 +785,7 @@ class EasyTierController( private fun getEasyTierConfig(): String { val prefs = activity.getSharedPreferences(EASYTIER_PREFS, Context.MODE_PRIVATE) val defaultConfig = "instance_name = \"Default\"\n" + - "hostname = \"moonlight-V+\"\n" + + "hostname = \"moonlight-remote\"\n" + "ipv4 = \"10.0.0.1/24\"\n" + "dhcp = false\n" + "listeners = [\"tcp://0.0.0.0:11010\", \"udp://0.0.0.0:11010\", \"wg://0.0.0.0:11011\"]\n" + @@ -769,6 +811,7 @@ class EasyTierController( activity.getSharedPreferences(EASYTIER_PREFS, Context.MODE_PRIVATE) .edit() .putString(KEY_TOML_CONFIG, EasyTierTomlCodec.build(config)) + .remove(KEY_ACTIVE_PROFILE) .apply() // 重新初始化 @@ -1044,5 +1087,8 @@ class EasyTierController( private const val TAG = "EasyTierController" private const val EASYTIER_PREFS = "easytier_preferences" private const val KEY_TOML_CONFIG = "toml_config_string" + private const val KEY_ACTIVE_PROFILE = "active_connection_profile" + private const val KEY_PROFILE_PREFIX = "connection_profile." + private const val KEY_PROFILE_HOST_PREFIX = "connection_profile_host." } } diff --git a/app/src/main/java/com/limelight/utils/easytier/EasyTierTomlCodec.kt b/app/src/main/java/com/limelight/utils/easytier/EasyTierTomlCodec.kt index 88eede69b6..3b8af0cb4c 100644 --- a/app/src/main/java/com/limelight/utils/easytier/EasyTierTomlCodec.kt +++ b/app/src/main/java/com/limelight/utils/easytier/EasyTierTomlCodec.kt @@ -1,9 +1,12 @@ package com.limelight.utils.easytier +import com.limelight.utils.remoteconnect.EasyTierConnectionProfile + internal data class EasyTierConfigUiState( val networkName: String = "", val networkSecret: String = "", val ipv4: String = "", + val dhcp: Boolean = false, val listeners: String = "", val peers: String = "", val useSmoltcp: Boolean = false, @@ -38,6 +41,7 @@ internal object EasyTierTomlCodec { networkName = extractValue(toml, "network_name", ""), networkSecret = extractValue(toml, "network_secret", ""), ipv4 = ipv4, + dhcp = extractValue(toml, "dhcp", "false").toBoolean(), listeners = extractListAsString(toml, "listeners"), peers = extractListAsString(toml, "uri"), useSmoltcp = extractValue(toml, "use_smoltcp", "false").toBoolean(), @@ -58,10 +62,12 @@ internal object EasyTierTomlCodec { fun build(config: EasyTierConfigUiState): String { val sb = StringBuilder() - appendTomlString(sb, "hostname", "moonlight-V+") + appendTomlString(sb, "hostname", "moonlight-remote") appendTomlString(sb, "instance_name", "Default") - sb.append("dhcp = false\n") - appendTomlString(sb, "ipv4", "${config.ipv4.ifBlank { DEFAULT_IPV4 }}/24", writeEmpty = true) + sb.append("dhcp = ${config.dhcp}\n") + if (!config.dhcp) { + appendTomlString(sb, "ipv4", "${config.ipv4.ifBlank { DEFAULT_IPV4 }}/24", writeEmpty = true) + } appendTomlStringArray(sb, "listeners", nonBlankLines(config.listeners)) @@ -93,6 +99,36 @@ internal object EasyTierTomlCodec { return sb.toString() } + fun buildConnectionProfile(profile: EasyTierConnectionProfile): String { + val sb = StringBuilder() + appendTomlString(sb, "hostname", "moonlight-remote") + appendTomlString(sb, "instance_name", "Default") + sb.append("dhcp = true\n") + appendTomlStringArray(sb, "listeners", listOf("tcp://0.0.0.0:0", "udp://0.0.0.0:0")) + appendTomlString(sb, "rpc_portal", "127.0.0.1:0") + + // Host-issued profiles must never accept peer-advertised subnet, DNS, or exit-node routes. + sb.append("exit_nodes = []\n") + sb.append("routes = []\n") + sb.append("proxy_network = []\n") + sb.append("\n[network_identity]\n") + appendTomlString(sb, "network_name", profile.networkName) + appendTomlString(sb, "network_secret", profile.networkSecret, writeEmpty = true) + + for (peer in profile.peers) { + appendTomlPeer(sb, peer) + } + + sb.append("\n[flags]\n") + sb.append("latency_first = true\n") + sb.append("enable_ipv6 = false\n") + sb.append("enable_exit_node = false\n") + sb.append("proxy_forward_by_system = false\n") + sb.append("accept_dns = false\n") + sb.append("relay_network_whitelist = \"\"\n") + return sb.toString() + } + private fun extractValue(toml: String, key: String, defaultValue: String): String { for (rawLine in toml.split("\n")) { val line = rawLine.trim() diff --git a/app/src/main/java/com/limelight/utils/remoteconnect/RemoteConnectCode.kt b/app/src/main/java/com/limelight/utils/remoteconnect/RemoteConnectCode.kt new file mode 100644 index 0000000000..ee372e2017 --- /dev/null +++ b/app/src/main/java/com/limelight/utils/remoteconnect/RemoteConnectCode.kt @@ -0,0 +1,137 @@ +package com.limelight.utils.remoteconnect + +import java.net.URI +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +internal data class EasyTierConnectionProfile( + val id: String, + val networkName: String, + val networkSecret: String, + val peers: List, + val hostVirtualIp: String +) + +internal data class RemoteConnectCode( + val host: String, + val port: Int, + val pin: String, + val name: String?, + val easyTierProfile: EasyTierConnectionProfile? +) + +/** Retains an in-flight connection across activity recreation without persisting its secret. */ +internal object PendingRemoteConnectState { + private var code: RemoteConnectCode? = null + + @Synchronized + fun stage(value: RemoteConnectCode) { + code = value + } + + @Synchronized + fun peek(): RemoteConnectCode? = code + + @Synchronized + fun consume(): RemoteConnectCode? = code.also { code = null } +} + +internal object RemoteConnectCodeParser { + private const val DEFAULT_SUNSHINE_PORT = 47989 + private val PROFILE_ID = Regex("[A-Za-z0-9._-]{1,128}") + private val IPV4 = Regex("(?:[0-9]{1,3}\\.){3}[0-9]{1,3}") + private val ALLOWED_PEER_SCHEMES = setOf("tcp", "udp", "wg", "ws", "wss", "quic") + + fun parse(raw: String, nowEpochSeconds: Long = System.currentTimeMillis() / 1000): RemoteConnectCode { + val uri = try { + URI(raw) + } catch (e: Exception) { + throw IllegalArgumentException("Invalid connection code", e) + } + + require(uri.scheme.equals("moonlight", ignoreCase = true) && uri.host.equals("pair", ignoreCase = true)) { + "Unsupported connection code" + } + + val query = parseQuery(uri.rawQuery) + val host = query.first("host")?.trim().orEmpty() + val pin = query.first("pin")?.trim().orEmpty() + require(host.isNotEmpty() && host.length <= 255) { "Missing host" } + require(pin.matches(Regex("[0-9]{4}"))) { "Invalid PIN" } + + val rawPort = query.first("port") + val port = rawPort?.toIntOrNull() + ?: if (rawPort == null) DEFAULT_SUNSHINE_PORT else throw IllegalArgumentException("Invalid port") + require(port in 1..65535) { "Invalid port" } + + val rawVersion = query.first("v") + val version = rawVersion?.toIntOrNull() + ?: if (rawVersion == null) 1 else throw IllegalArgumentException("Invalid connection code version") + require(version == 1 || version == 2) { "Unsupported connection code version" } + + val profile = if (version == 2) parseEasyTierProfile(query, host, nowEpochSeconds) else null + return RemoteConnectCode( + host = host, + port = port, + pin = pin, + name = query.first("name")?.take(256), + easyTierProfile = profile + ) + } + + private fun parseEasyTierProfile( + query: Map>, + sunshineHost: String, + nowEpochSeconds: Long + ): EasyTierConnectionProfile { + val expiresAt = query.first("expires")?.toLongOrNull() + ?: throw IllegalArgumentException("Missing expiry") + require(expiresAt >= nowEpochSeconds && expiresAt <= nowEpochSeconds + 24 * 60 * 60) { + "Expired connection code" + } + + val id = query.first("profile")?.trim().orEmpty() + val networkName = query.first("et_name")?.trim().orEmpty() + val networkSecret = query.first("et_secret").orEmpty() + val hostVirtualIp = query.first("et_host")?.trim().orEmpty() + val peers = query["et_peer"].orEmpty().map(String::trim).filter(String::isNotEmpty) + + require(PROFILE_ID.matches(id)) { "Invalid profile identifier" } + require(networkName.isNotEmpty() && networkName.length <= 128) { "Invalid network name" } + require(networkSecret.length in 16..256) { "Invalid network secret" } + require(isValidIpv4(hostVirtualIp) && sunshineHost == hostVirtualIp) { "Invalid virtual host address" } + require(peers.isNotEmpty() && peers.size <= 8) { "Invalid peer list" } + peers.forEach { peer -> + require(peer.length <= 512) { "Peer URL is too long" } + val peerUri = runCatching { URI(peer) }.getOrNull() + ?: throw IllegalArgumentException("Invalid peer URL") + require(peerUri.scheme?.lowercase() in ALLOWED_PEER_SCHEMES && peerUri.host != null) { + "Unsupported peer URL" + } + } + + return EasyTierConnectionProfile(id, networkName, networkSecret, peers, hostVirtualIp) + } + + private fun isValidIpv4(value: String): Boolean { + if (!IPV4.matches(value)) return false + return value.split('.').all { it.toIntOrNull() in 0..255 } + } + + private fun parseQuery(rawQuery: String?): Map> { + if (rawQuery.isNullOrEmpty()) return emptyMap() + val result = linkedMapOf>() + rawQuery.split('&').forEach { entry -> + val parts = entry.split('=', limit = 2) + val key = decode(parts[0]) + val value = decode(parts.getOrElse(1) { "" }) + result.getOrPut(key) { mutableListOf() }.add(value) + } + return result + } + + private fun decode(value: String): String = + URLDecoder.decode(value, StandardCharsets.UTF_8.name()) + + private fun Map>.first(key: String): String? = get(key)?.firstOrNull() +} diff --git a/app/src/main/jniLibs/x86_64/README.md b/app/src/main/jniLibs/x86_64/README.md new file mode 100644 index 0000000000..ec8ac8b1db --- /dev/null +++ b/app/src/main/jniLibs/x86_64/README.md @@ -0,0 +1,17 @@ +# EasyTier x86_64 Android test libraries + +These libraries exist only to support emulator E2E builds. They are packaged +when Gradle is invoked with `-PenableX8664TestAbi=true`; normal release builds +continue to package the production ARM ABI only. + +- EasyTier source: official `v2.6.4` release +- Target: `x86_64-linux-android`, Android API 21 +- Android NDK: 28.2 +- Rust: 1.95.0 +- `libeasytier_android_jni.so` SHA-256: + `80ECDEBAE94D01D1EC6AF6B14ED02D134F1C94FAB7E45F91F622897483E49A1D` +- `libeasytier_ffi.so` SHA-256: + `795BA53B2D23D20CD942C30A15CE95E6B6F65607ED55ADF791AC34AAFA0C8F38` + +The JNI library is linked with an explicit `DT_NEEDED` dependency on +`libeasytier_ffi.so`, matching Android's namespace-based native loader. diff --git a/app/src/main/jniLibs/x86_64/libeasytier_android_jni.so b/app/src/main/jniLibs/x86_64/libeasytier_android_jni.so new file mode 100644 index 0000000000..cc82f1c39d Binary files /dev/null and b/app/src/main/jniLibs/x86_64/libeasytier_android_jni.so differ diff --git a/app/src/main/jniLibs/x86_64/libeasytier_ffi.so b/app/src/main/jniLibs/x86_64/libeasytier_ffi.so new file mode 100644 index 0000000000..1597225de4 Binary files /dev/null and b/app/src/main/jniLibs/x86_64/libeasytier_ffi.so differ diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 3e1fac971a..830a769a30 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -164,6 +164,13 @@ 无法识别的二维码 二维码配对中… 配对成功! + 正在准备远程连接… + 无法配置远程连接。 + 主机远程连接尚未就绪,请在主机端修复后重新扫码。 + 确认连接 + 要连接到 %1$s(%2$s)吗?这会在本设备保存一个私有网络配置。VPN 只路由到这台主机的流量;配置会一直保留,直到你替换或移除它。 + 要与 %1$s(%2$s)配对吗?请仅在信任打开此链接的应用或网站时继续。 + 连接 正在连接电脑…… 无法连接至指定电脑。请确保所需端口没有被防火墙阻止。 无法通过 VPN 连接至指定电脑。请检查 VPN 是否包含该私网网段的路由、是否排除了 Moonlight,以及主机防火墙是否允许 VPN 网段访问。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2bded2ad15..a6ee7fa291 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -223,6 +223,13 @@ Unrecognized QR code QR pairing… Paired successfully! + Preparing remote connection… + Unable to configure remote connection. + The host remote connection is not ready. Repair it on the host, then scan again. + Confirm connection + Connect to %1$s (%2$s)? This saves a private-network profile on this device. Only traffic to this host is routed through the VPN; the profile remains until you replace or remove it. + Pair with %1$s (%2$s)? Only continue if you trust the app or site that opened this link. + Connect Connecting to the PC… Unable to connect to the specified computer. Make sure the required ports are allowed through the firewall. Unable to connect to the specified computer through the VPN. Check that the VPN routes this private subnet, does not exclude Moonlight, and that the host firewall allows the VPN network. diff --git a/app/src/test/java/com/limelight/utils/easytier/EasyTierTomlCodecTest.kt b/app/src/test/java/com/limelight/utils/easytier/EasyTierTomlCodecTest.kt index 40e76c433d..8126ac2475 100644 --- a/app/src/test/java/com/limelight/utils/easytier/EasyTierTomlCodecTest.kt +++ b/app/src/test/java/com/limelight/utils/easytier/EasyTierTomlCodecTest.kt @@ -22,6 +22,14 @@ class EasyTierTomlCodecTest { assertTrue(toml.contains("ipv4 = \"10.0.0.1/24\"")) } + @Test + fun dhcpProfileOmitsStaticIpv4() { + val toml = EasyTierTomlCodec.build(EasyTierConfigUiState(dhcp = true)) + + assertTrue(toml.contains("dhcp = true")) + assertTrue(!toml.contains("ipv4 =")) + } + @Test fun stringsRoundTripEscapedCharacters() { val original = EasyTierConfigUiState( diff --git a/app/src/test/java/com/limelight/utils/remoteconnect/RemoteConnectCodeParserTest.kt b/app/src/test/java/com/limelight/utils/remoteconnect/RemoteConnectCodeParserTest.kt new file mode 100644 index 0000000000..074cfe9e44 --- /dev/null +++ b/app/src/test/java/com/limelight/utils/remoteconnect/RemoteConnectCodeParserTest.kt @@ -0,0 +1,98 @@ +package com.limelight.utils.remoteconnect + +import com.limelight.utils.easytier.EasyTierTomlCodec +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class RemoteConnectCodeParserTest { + @Test + fun legacyPairingCodeRemainsSupported() { + val code = RemoteConnectCodeParser.parse( + "moonlight://pair?host=192.168.1.5&port=47989&pin=1234&name=PC", + nowEpochSeconds = 100 + ) + + assertEquals("192.168.1.5", code.host) + assertEquals(47989, code.port) + assertNull(code.easyTierProfile) + } + + @Test + fun pendingConnectionSurvivesControllerRecreationUntilConsumed() { + val code = RemoteConnectCodeParser.parse( + "moonlight://pair?host=192.168.1.5&port=47989&pin=1234&name=PC", + nowEpochSeconds = 100 + ) + + PendingRemoteConnectState.consume() + PendingRemoteConnectState.stage(code) + assertEquals(code, PendingRemoteConnectState.peek()) + assertEquals(code, PendingRemoteConnectState.consume()) + assertNull(PendingRemoteConnectState.peek()) + } + + @Test + fun versionTwoParsesSafeEasyTierProfile() { + val code = RemoteConnectCodeParser.parse( + "moonlight://pair?v=2&host=10.86.24.1&port=47989&pin=1234" + + "&profile=host-abc&et_host=10.86.24.1&et_name=remote-host-abc" + + "&et_secret=0123456789abcdef0123456789abcdef" + + "&et_peer=udp%3A%2F%2Fpublic.easytier.top%3A11010&expires=1000", + nowEpochSeconds = 100 + ) + + assertNotNull(code.easyTierProfile) + assertEquals(true, EasyTierTomlCodec.parseConfig( + EasyTierTomlCodec.buildConnectionProfile(code.easyTierProfile!!) + ).dhcp) + } + + @Test(expected = IllegalArgumentException::class) + fun versionTwoRejectsExpiredCode() { + RemoteConnectCodeParser.parse( + "moonlight://pair?v=2&host=10.86.24.1&pin=1234&profile=host-abc" + + "&et_host=10.86.24.1&et_name=remote-host-abc" + + "&et_secret=0123456789abcdef&et_peer=udp%3A%2F%2Fpeer.example%3A11010&expires=99", + nowEpochSeconds = 100 + ) + } + + @Test(expected = IllegalArgumentException::class) + fun versionTwoRejectsTrafficHijackingPeerScheme() { + RemoteConnectCodeParser.parse( + "moonlight://pair?v=2&host=10.86.24.1&pin=1234&profile=host-abc" + + "&et_host=10.86.24.1&et_name=remote-host-abc" + + "&et_secret=0123456789abcdef&et_peer=file%3A%2F%2Fevil&expires=1000", + nowEpochSeconds = 100 + ) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsMalformedPresentPort() { + RemoteConnectCodeParser.parse("moonlight://pair?host=192.168.1.5&port=abc&pin=1234") + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsMalformedPresentVersion() { + RemoteConnectCodeParser.parse("moonlight://pair?v=abc&host=192.168.1.5&pin=1234") + } + + @Test + fun hostIssuedProfileDisablesRouteAndDnsTakeover() { + val profile = RemoteConnectCodeParser.parse( + "moonlight://pair?v=2&host=100.86.24.1&pin=1234&profile=host-abc" + + "&et_host=100.86.24.1&et_name=remote-host-abc" + + "&et_secret=0123456789abcdef&et_peer=udp%3A%2F%2Fpeer.example%3A11010&expires=1000", + nowEpochSeconds = 100 + ).easyTierProfile!! + val toml = EasyTierTomlCodec.buildConnectionProfile(profile) + + assertEquals(true, toml.contains("exit_nodes = []")) + assertEquals(true, toml.contains("routes = []")) + assertEquals(true, toml.contains("proxy_network = []")) + assertEquals(true, toml.contains("accept_dns = false")) + assertEquals(true, toml.contains("enable_exit_node = false")) + } +}