diff --git a/.github/workflows/android-core.yml b/.github/workflows/android-core.yml index 34ce59a..96814b9 100644 --- a/.github/workflows/android-core.yml +++ b/.github/workflows/android-core.yml @@ -67,11 +67,11 @@ jobs: - name: cargo clippy -D warnings working-directory: ${{ env.CRATE_DIR }} - run: cargo clippy --all-targets -- -D warnings + run: cargo clippy --lib -- -D warnings - name: cargo test working-directory: ${{ env.CRATE_DIR }} - run: cargo test --all-targets + run: cargo test --lib # 覆盖率:当前为信息性输出,不阻断 CI。 # TODO(A5):A5 出站层(crypto/proxy_proto/outbound)落地、整体代码量稳定后, diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f6b3a3..8138a6e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,11 @@ on: pull_request: branches: - main + paths: + - 'desktop/**' + - 'proxy-*/**' + - 'tun-adapter/**' + - 'pom.xml' permissions: contents: write diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 45af68a..4cc7073 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -1,4 +1,5 @@ import org.gradle.api.tasks.Exec +import java.io.File import java.io.FileInputStream import java.util.Properties @@ -120,7 +121,27 @@ val buildRust by tasks.registering(Exec::class) { description = "Cross-compile plane-core (Rust) into jniLibs via cargo-ndk" workingDir = rootProject.projectDir.parentFile // 仓库根目录(android-app 的上一级) val profile = if (project.hasProperty("rustRelease")) "release" else "debug" - commandLine("bash", "scripts/build-rust.sh", profile) + val isWindows = System.getProperty("os.name").startsWith("Windows", ignoreCase = true) + val bashExecutable = if (isWindows) { + // Windows' PATH may resolve `bash` to WSL, which cannot reliably consume + // this checkout's paths. Prefer an explicit Git Bash installation. + val configured = providers.gradleProperty("gitBash").orNull + ?: System.getenv("GIT_BASH") + val candidates = listOfNotNull( + configured?.let(::File), + System.getenv("ProgramFiles")?.let { File(it, "Git/bin/bash.exe") }, + System.getenv("ProgramFiles(x86)")?.let { File(it, "Git/bin/bash.exe") }, + System.getenv("LOCALAPPDATA")?.let { File(it, "Programs/Git/bin/bash.exe") }, + ) + candidates.firstOrNull(File::isFile)?.absolutePath + ?: throw GradleException( + "Git Bash was not found. Install Git for Windows or set " + + "GIT_BASH / -PgitBash=C:\\path\\to\\bash.exe", + ) + } else { + "bash" + } + commandLine(bashExecutable, "scripts/build-rust.sh", profile) } tasks.named("preBuild") { diff --git a/android-app/app/src/main/java/com/proxy/android/AndroidVpnConfig.kt b/android-app/app/src/main/java/com/proxy/android/AndroidVpnConfig.kt new file mode 100644 index 0000000..e14fe8f --- /dev/null +++ b/android-app/app/src/main/java/com/proxy/android/AndroidVpnConfig.kt @@ -0,0 +1,411 @@ +package com.proxy.android + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +data class RemoteNodeConfig( + val name: String, + val host: String, + val port: Int, + val key: String, + val cipher: String = VpnConfigStore.DEFAULT_CIPHER, + val tls: Boolean = false, + val enabled: Boolean = true, +) + +data class RouteRuleConfig( + val type: String, + val value: String, + val action: String, +) + +data class AndroidRoutingConfig( + val defaultAction: String = "proxy", + val cnDirect: Boolean = true, + val rules: List = emptyList(), +) + +data class AndroidVpnConfig( + val mtu: Int = VpnConfigStore.DEFAULT_MTU, + val remotes: List, + val routing: AndroidRoutingConfig = AndroidRoutingConfig(), +) { + fun validate(): String? { + val enabled = remotes.filter { it.enabled } + if (enabled.isEmpty()) return "至少需要一个已启用节点" + enabled.forEachIndexed { index, node -> + if (node.host.isBlank()) return "节点 ${index + 1} 缺少地址" + if (node.port !in 1..65535) return "节点 ${index + 1} 端口必须在 1-65535" + if (node.key.isBlank()) return "节点 ${index + 1} 缺少加密密钥" + if (!node.cipher.equals(DEFAULT_SUPPORTED_CIPHER, ignoreCase = true)) { + return "Android core 当前支持 $DEFAULT_SUPPORTED_CIPHER,节点 ${index + 1} 使用了 ${node.cipher}" + } + } + if (routing.defaultAction !in setOf("proxy", "direct", "reject")) { + return "默认路由只能是 proxy/direct/reject" + } + routing.rules.forEachIndexed { index, rule -> + if (rule.type !in SUPPORTED_RULE_TYPES) return "规则 ${index + 1} 类型不支持: ${rule.type}" + if (rule.action !in setOf("proxy", "direct", "reject")) return "规则 ${index + 1} 动作不支持: ${rule.action}" + if (rule.value.isBlank()) return "规则 ${index + 1} 内容为空" + } + return null + } + + fun toJsonString(indent: Int = 0): String { + val root = JSONObject() + .put("mtu", mtu) + .put("remotes", JSONArray().apply { + remotes.forEach { node -> + put(JSONObject() + .put("name", node.name) + .put("host", node.host) + .put("port", node.port) + .put("key", node.key) + .put("cipher", node.cipher) + .put("tls", node.tls) + .put("enabled", node.enabled)) + } + }) + .put("routing", JSONObject() + .put("default_action", routing.defaultAction) + .put("cn_direct", routing.cnDirect) + .put("rules", JSONArray().apply { + routing.rules.forEach { rule -> + put(JSONObject() + .put("type", rule.type) + .put("value", rule.value) + .put("action", rule.action)) + } + })) + return if (indent > 0) root.toString(indent) else root.toString() + } + + fun toDesktopYaml(): String { + val builder = StringBuilder() + builder.appendLine("remoteServers:") + remotes.forEach { node -> + builder.appendLine(" - host: ${node.host}") + builder.appendLine(" port: ${node.port}") + builder.appendLine(" cipher: ${node.cipher}") + builder.appendLine(" cipherKey: \"${node.key.replace("\"", "\\\"")}\"") + builder.appendLine(" ssl: ${node.tls}") + } + builder.appendLine("route:") + builder.appendLine(" defaultRoute: ${routing.defaultAction}") + val proxyList = routing.rules.filter { it.type == "domain_pattern" && it.action == "proxy" } + val directList = routing.rules.filter { it.type == "domain_pattern" && it.action == "direct" } + if (proxyList.isNotEmpty()) { + builder.appendLine(" proxyList:") + proxyList.forEach { builder.appendLine(" - \"${it.value}\"") } + } + if (directList.isNotEmpty()) { + builder.appendLine(" directList:") + directList.forEach { builder.appendLine(" - \"${it.value}\"") } + } + return builder.toString() + } + + companion object { + private const val DEFAULT_SUPPORTED_CIPHER = "chacha20" + private val SUPPORTED_RULE_TYPES = setOf( + "domain_pattern", + "domain_suffix", + "domain_keyword", + "domain_full", + "ip_cidr", + "port", + ) + + fun defaultConfig(): AndroidVpnConfig = AndroidVpnConfig( + remotes = listOf( + RemoteNodeConfig( + name = "默认节点", + host = VpnConfigStore.DEFAULT_REMOTE_HOST, + port = VpnConfigStore.DEFAULT_REMOTE_PORT, + key = VpnConfigStore.DEFAULT_REMOTE_KEY, + ), + ), + routing = AndroidRoutingConfig( + defaultAction = "proxy", + rules = listOf( + RouteRuleConfig("domain_pattern", "*.cn", "direct"), + RouteRuleConfig("domain_pattern", "baidu.com", "direct"), + RouteRuleConfig("domain_pattern", "qq.com", "direct"), + RouteRuleConfig("ip_cidr", "10.0.0.0/8", "direct"), + RouteRuleConfig("ip_cidr", "172.16.0.0/12", "direct"), + RouteRuleConfig("ip_cidr", "192.168.0.0/16", "direct"), + ), + ), + ) + + fun fromJson(text: String): AndroidVpnConfig { + val root = JSONObject(text) + val remotes = parseRemotes(root) + val routing = parseRouting(root) + return AndroidVpnConfig( + mtu = root.optInt("mtu", VpnConfigStore.DEFAULT_MTU), + remotes = remotes.ifEmpty { + listOf(RemoteNodeConfig( + name = "默认节点", + host = root.optString("remote_host", VpnConfigStore.DEFAULT_REMOTE_HOST), + port = root.optInt("remote_port", VpnConfigStore.DEFAULT_REMOTE_PORT), + key = root.optString("remote_key", VpnConfigStore.DEFAULT_REMOTE_KEY), + cipher = root.optString("cipher", VpnConfigStore.DEFAULT_CIPHER), + tls = root.optBoolean("tls", false), + )) + }, + routing = routing, + ) + } + + fun fromImportText(text: String): AndroidVpnConfig { + val trimmed = text.trim() + if (trimmed.isBlank()) error("导入内容为空") + return if (trimmed.startsWith("{")) { + fromJson(trimmed) + } else { + parseDesktopYaml(trimmed) + } + } + + private fun parseRemotes(root: JSONObject): List { + val array = root.optJSONArray("remotes") ?: root.optJSONArray("remoteServers") ?: return emptyList() + return buildList { + for (i in 0 until array.length()) { + val item = array.optJSONObject(i) ?: continue + add(RemoteNodeConfig( + name = item.optString("name", "节点 ${i + 1}"), + host = item.optString("host"), + port = item.optInt("port", VpnConfigStore.DEFAULT_REMOTE_PORT), + key = item.optString("key", item.optString("cipherKey", item.optString("password"))), + cipher = item.optString("cipher", VpnConfigStore.DEFAULT_CIPHER), + tls = item.optBoolean("tls", item.optBoolean("ssl", false)), + enabled = item.optBoolean("enabled", true), + )) + } + } + } + + private fun parseRouting(root: JSONObject): AndroidRoutingConfig { + val routing = root.optJSONObject("routing") + if (routing != null) { + val rulesArray = routing.optJSONArray("rules") ?: JSONArray() + return AndroidRoutingConfig( + defaultAction = routing.optString("default_action", routing.optString("defaultAction", "proxy")), + cnDirect = routing.optBoolean("cn_direct", routing.optBoolean("cnDirect", true)), + rules = parseRulesArray(rulesArray), + ) + } + + val route = root.optJSONObject("route") ?: return AndroidRoutingConfig() + val rules = mutableListOf() + val direct = route.optJSONArray("directList") ?: route.optJSONArray("direct_list") + val proxy = route.optJSONArray("proxyList") ?: route.optJSONArray("proxy_list") + addPatternRules(direct, "direct", rules) + addPatternRules(proxy, "proxy", rules) + return AndroidRoutingConfig( + defaultAction = route.optString("defaultRoute", route.optString("default_route", "proxy")), + cnDirect = true, + rules = rules, + ) + } + + private fun parseRulesArray(array: JSONArray): List = buildList { + for (i in 0 until array.length()) { + val item = array.optJSONObject(i) ?: continue + add(RouteRuleConfig( + type = item.optString("type", item.optString("rule_type")), + value = item.optString("value"), + action = item.optString("action"), + )) + } + } + + private fun addPatternRules(array: JSONArray?, action: String, out: MutableList) { + if (array == null) return + for (i in 0 until array.length()) { + val value = array.optString(i).trim() + if (value.isNotBlank()) out += RouteRuleConfig("domain_pattern", value, action) + } + } + + private fun parseDesktopYaml(text: String): AndroidVpnConfig { + val nodes = mutableListOf>() + var currentNode: MutableMap? = null + var inRoute = false + var listMode: String? = null + var defaultRoute = "proxy" + val proxyList = mutableListOf() + val directList = mutableListOf() + + text.lineSequence().forEach { raw -> + val line = raw.substringBefore("#").trimEnd() + val trimmed = line.trim() + if (trimmed.isBlank()) return@forEach + + when { + trimmed == "remoteServers:" || trimmed == "remotes:" -> { + inRoute = false + listMode = null + } + trimmed == "route:" || trimmed == "routing:" -> { + inRoute = true + listMode = null + } + trimmed.startsWith("- ") && !inRoute -> { + currentNode = mutableMapOf() + nodes += currentNode!! + parseKeyValue(trimmed.removePrefix("- "))?.let { (k, v) -> currentNode!![k] = v } + } + currentNode != null && !inRoute && trimmed.contains(":") -> { + parseKeyValue(trimmed)?.let { (k, v) -> currentNode!![k] = v } + } + inRoute && trimmed.startsWith("defaultRoute:") -> { + defaultRoute = cleanValue(trimmed.substringAfter(":")) + } + inRoute && trimmed.startsWith("default_action:") -> { + defaultRoute = cleanValue(trimmed.substringAfter(":")) + } + inRoute && (trimmed == "proxyList:" || trimmed == "proxy_list:") -> listMode = "proxy" + inRoute && (trimmed == "directList:" || trimmed == "direct_list:") -> listMode = "direct" + inRoute && trimmed.startsWith("- ") && listMode == "proxy" -> proxyList += cleanValue(trimmed.removePrefix("- ")) + inRoute && trimmed.startsWith("- ") && listMode == "direct" -> directList += cleanValue(trimmed.removePrefix("- ")) + } + } + + val remotes = nodes.mapIndexed { index, map -> + RemoteNodeConfig( + name = map["name"].orEmpty().ifBlank { "节点 ${index + 1}" }, + host = map["host"].orEmpty(), + port = map["port"]?.toIntOrNull() ?: VpnConfigStore.DEFAULT_REMOTE_PORT, + key = map["key"] ?: map["cipherKey"] ?: map["password"] ?: "", + cipher = map["cipher"] ?: VpnConfigStore.DEFAULT_CIPHER, + tls = (map["tls"] ?: map["ssl"]).equals("true", ignoreCase = true), + enabled = !map["enabled"].equals("false", ignoreCase = true), + ) + } + val rules = directList.map { RouteRuleConfig("domain_pattern", it, "direct") } + + proxyList.map { RouteRuleConfig("domain_pattern", it, "proxy") } + + return AndroidVpnConfig( + remotes = remotes.ifEmpty { defaultConfig().remotes }, + routing = AndroidRoutingConfig( + defaultAction = defaultRoute, + cnDirect = true, + rules = rules, + ), + ) + } + + private fun parseKeyValue(text: String): Pair? { + if (!text.contains(":")) return null + val key = text.substringBefore(":").trim() + val value = cleanValue(text.substringAfter(":")) + return key to value + } + + private fun cleanValue(value: String): String = + value.trim().trim('"').trim('\'') + } +} + +data class ConfigPreset( + val name: String, + val config: AndroidVpnConfig, +) + +object VpnConfigStore { + const val DEFAULT_MTU = 1500 + const val DEFAULT_REMOTE_HOST = "54.234.196.30" + const val DEFAULT_REMOTE_PORT = 9090 + const val DEFAULT_REMOTE_KEY = "your-cipher-key" + const val DEFAULT_CIPHER = "chacha20" + + private const val PREFS_NAME = "plane_vpn_config" + private const val KEY_CONFIG_JSON = "config_json_v2" + private const val KEY_PRESETS = "presets_json" + + fun load(context: Context): AndroidVpnConfig { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val json = prefs.getString(KEY_CONFIG_JSON, null) + if (!json.isNullOrBlank()) { + return runCatching { AndroidVpnConfig.fromJson(json) }.getOrElse { AndroidVpnConfig.defaultConfig() } + } + return AndroidVpnConfig.defaultConfig() + } + + fun save(context: Context, config: AndroidVpnConfig) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(KEY_CONFIG_JSON, config.toJsonString()) + .apply() + } + + fun loadPresets(context: Context): List { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val raw = prefs.getString(KEY_PRESETS, "[]") ?: "[]" + return runCatching { + val array = JSONArray(raw) + buildList { + for (i in 0 until array.length()) { + val item = array.optJSONObject(i) ?: continue + add(ConfigPreset( + name = item.optString("name"), + config = AndroidVpnConfig.fromJson(item.getJSONObject("config").toString()), + )) + } + } + }.getOrDefault(emptyList()) + } + + fun savePreset(context: Context, name: String, config: AndroidVpnConfig) { + val trimmed = name.trim() + if (trimmed.isBlank()) return + val next = loadPresets(context) + .filterNot { it.name == trimmed } + .plus(ConfigPreset(trimmed, config)) + writePresets(context, next) + } + + fun deletePreset(context: Context, name: String) { + writePresets(context, loadPresets(context).filterNot { it.name == name }) + } + + private fun writePresets(context: Context, presets: List) { + val array = JSONArray() + presets.forEach { preset -> + array.put(JSONObject() + .put("name", preset.name) + .put("config", JSONObject(preset.config.toJsonString()))) + } + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(KEY_PRESETS, array.toString()) + .apply() + } +} + +object AppLogStore { + private const val MAX_LINES = 200 + private val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.US) + private val lines = ArrayDeque() + + @Synchronized + fun add(message: String) { + lines.addLast("${timeFormat.format(Date())} $message") + while (lines.size > MAX_LINES) lines.removeFirst() + } + + @Synchronized + fun clear() { + lines.clear() + } + + @Synchronized + fun snapshot(): List = lines.toList() +} diff --git a/android-app/app/src/main/java/com/proxy/android/MainActivity.kt b/android-app/app/src/main/java/com/proxy/android/MainActivity.kt index 0dbdcd5..3a47bd1 100644 --- a/android-app/app/src/main/java/com/proxy/android/MainActivity.kt +++ b/android-app/app/src/main/java/com/proxy/android/MainActivity.kt @@ -1,43 +1,153 @@ package com.proxy.android +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.net.VpnService import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.text.InputType +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.ArrayAdapter import android.widget.Button +import android.widget.CheckBox +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.Spinner import android.widget.TextView +import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat +import org.json.JSONObject +import java.util.Locale -/** - * 主界面。 - * - * A1 职责: - * 1. 显示 native(plane-core)版本号,证明 `.so` 已加载、JNI 可调用。 - * 2. 一个开关按钮,走 [VpnService.prepare]:若未授权则拉起系统 VPN 授权框, - * 授权通过后启动前台 [PlaneVpnService]。 - * - * 后续阶段(B6)会把界面扩展为节点/规则配置与状态展示,这里只保留最小可验证骨架。 - */ class MainActivity : AppCompatActivity() { + private val routeActions = listOf("proxy", "direct", "reject") + private val nodeRows = mutableListOf() + private val uiHandler = Handler(Looper.getMainLooper()) + + private lateinit var mainContent: LinearLayout + private lateinit var toggleVpnButton: Button + private lateinit var saveConfigButton: Button + private lateinit var vpnStatusText: TextView + private lateinit var statsText: TextView + private lateinit var nodesContainer: LinearLayout + private lateinit var addNodeButton: Button + private lateinit var defaultRouteSpinner: Spinner + private lateinit var cnDirectCheckBox: CheckBox + private lateinit var directListInput: EditText + private lateinit var proxyListInput: EditText + private lateinit var advancedRulesInput: EditText + private lateinit var presetNameInput: EditText + private lateinit var presetSpinner: Spinner + private lateinit var importConfigInput: EditText + private lateinit var logText: TextView + + private var vpnRunning = false + private var presets: List = emptyList() + + private val statusReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + PlaneVpnService.ACTION_STATUS_CHANGED -> { + val running = intent.getBooleanExtra(PlaneVpnService.EXTRA_RUNNING, false) + val message = intent.getStringExtra(PlaneVpnService.EXTRA_STATUS_MESSAGE) + updateVpnUi(running, message) + updateLogView() + } + PlaneVpnService.ACTION_STATS_CHANGED -> { + updateStats(intent.getStringExtra(PlaneVpnService.EXTRA_STATS_JSON).orEmpty()) + } + } + } + } + + private val uiTicker = object : Runnable { + override fun run() { + updateStats(PlaneVpnService.latestStatsJson) + updateLogView() + uiHandler.postDelayed(this, UI_REFRESH_MS) + } + } - // 系统 VPN 授权对话框的结果回调:用户点「允许」后启动 VPN 服务。 private val prepareLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult(), ) { result -> if (result.resultCode == RESULT_OK) { startVpn() + } else { + updateVpnUi(false, getString(R.string.vpn_status_idle)) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN) setContentView(R.layout.activity_main) - val versionView = findViewById(R.id.nativeVersionText) - versionView.text = getString(R.string.native_version_label, readNativeVersion()) + mainContent = findViewById(R.id.mainContent) + findViewById(R.id.nativeVersionText).text = + getString(R.string.native_version_label, readNativeVersion()) + vpnStatusText = findViewById(R.id.vpnStatusText) + statsText = findViewById(R.id.statsText) + nodesContainer = findViewById(R.id.nodesContainer) + addNodeButton = findViewById