From 544a68bbd46966e717d965068313a1582d96279b Mon Sep 17 00:00:00 2001 From: Bluemangoo Date: Fri, 30 Oct 2026 23:40:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E8=87=AA=E5=AE=9A=E4=B9=89=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit null、导入、mixin ignore --- app/build.gradle.kts | 4 +- .../indpjsk/data/CaptureProfileStep.kt | 1 + .../bluemangoo/indpjsk/data/MixinProfiles.kt | 20 +- .../indpjsk/ui/UploadSettingsScreen.kt | 212 ++++++++++++++++-- .../bluemangoo/indpjsk/upload/UploadStore.kt | 181 +++++++++++---- .../net/bluemangoo/indpjsk/upload/Uploader.kt | 50 ++++- .../indpjsk/vpn/CaptureVpnService.kt | 4 +- core_vpn/src/mitm.rs | 17 ++ upload-config.d.ts | 19 +- 9 files changed, 429 insertions(+), 79 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c40b50f..76b3fc3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,8 +18,8 @@ android { applicationId = "net.bluemangoo.indpjsk" minSdk = appMinSdk targetSdk = 37 - versionCode = 5 - versionName = "1.4" + versionCode = 6 + versionName = "1.5" } diff --git a/app/src/main/java/net/bluemangoo/indpjsk/data/CaptureProfileStep.kt b/app/src/main/java/net/bluemangoo/indpjsk/data/CaptureProfileStep.kt index fb14e24..bf454e9 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/data/CaptureProfileStep.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/data/CaptureProfileStep.kt @@ -16,6 +16,7 @@ data class CaptureProfileStep( val fileName: String, var postAction: suspend (CaptureResult) -> Unit = {}, val uploadType: String? = null, + val explicitUploadIds: Set = emptySet(), ) data class CaptureResult( diff --git a/app/src/main/java/net/bluemangoo/indpjsk/data/MixinProfiles.kt b/app/src/main/java/net/bluemangoo/indpjsk/data/MixinProfiles.kt index 9c9c2a1..19cc78c 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/data/MixinProfiles.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/data/MixinProfiles.kt @@ -45,9 +45,24 @@ object MixinProfiles { } } } + + val upload = mixin.upload + if (upload != null && !upload.ignoreAll) { + for ((region, steps) in upload.ignore) { + if (region.lowercase() !in REGION_IDS) { + return "\"$region\" 不是内置配置" + } + val invalidStep = steps.firstOrNull { it.lowercase() !in PredefinedProfiles.stepTargets } + if (invalidStep != null) { + return "\"$invalidStep\" 不是内置类型" + } + } + } return null } + private val REGION_IDS = setOf("cn", "jp", "en", "tw", "kr") + private fun collect(): LinkedHashMap { val profiles = linkedMapOf() @@ -84,7 +99,10 @@ object MixinProfiles { val steps = base.steps.map { step -> val extra = acc.steps[step.name.trim().lowercase()] ?: return@map step - step.copy(hosts = (step.hosts + extra.hosts).distinctBy { it.lowercase() }) + step.copy( + hosts = (step.hosts + extra.hosts).distinctBy { it.lowercase() }, + explicitUploadIds = step.explicitUploadIds + extra.uploadProfileIds, + ) } + acc.steps.values .filter { it.name.trim().lowercase() !in builtInStepKeys } .mapNotNull { buildStep(it, usedFileNames, usedIds) } diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/UploadSettingsScreen.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/UploadSettingsScreen.kt index 782f858..1e899ff 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/ui/UploadSettingsScreen.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/UploadSettingsScreen.kt @@ -5,7 +5,10 @@ import android.content.ClipboardManager import android.content.Context import android.widget.Toast import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -27,10 +30,18 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import net.bluemangoo.indpjsk.R import net.bluemangoo.indpjsk.data.MixinProfiles import net.bluemangoo.indpjsk.upload.* import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.atomic.AtomicReference @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -43,6 +54,7 @@ fun UploadSettingsScreen(onBack: () -> Unit) { if (target != null) { UploadEditScreen( initial = target.initial, + isNew = target.index == null, takenIds = entries .filterIndexed { index, _ -> index != target.index } .map { it.profile.id } @@ -142,6 +154,17 @@ fun UploadSettingsScreen(onBack: () -> Unit) { ) } } + +} + +@Composable +private fun ImportAction(text: String, onClick: () -> Unit) { + ListItem( + headlineContent = { + Text(text, style = MaterialTheme.typography.bodyMedium) + }, + modifier = Modifier.clickable(onClick = onClick) + ) } private data class UploadEditTarget( @@ -221,6 +244,7 @@ private fun UploadTableRow( @Composable private fun UploadEditScreen( initial: UploadEntry?, + isNew: Boolean, takenIds: Set, onCancel: () -> Unit, onSave: (UploadEntry) -> Unit, @@ -282,6 +306,91 @@ private fun UploadEditScreen( var importText by remember { mutableStateOf("") } var importError by remember { mutableStateOf(null) } + // 从 JSON 导入:URL / 文件 / 直接打开 JSON 编辑器 + var showImportMenu by remember { mutableStateOf(false) } + var showUrlDialog by remember { mutableStateOf(false) } + var urlText by remember { mutableStateOf("") } + var loading by remember { mutableStateOf(false) } + var loadError by remember { mutableStateOf(null) } + + val scope = rememberCoroutineScope() + val importJob = remember { AtomicReference(null) } + val importConnection = remember { AtomicReference(null) } + + fun openJsonEditor(text: String) { + importText = text + importError = null + importDialog = true + } + + fun finishLoad(text: String) { + if (text.isBlank()) { + loadError = "内容为空" + } else { + openJsonEditor(text) + } + } + + fun cancelImport() { + importConnection.getAndSet(null)?.disconnect() + importJob.getAndSet(null)?.cancel() + loading = false + } + + fun loadFromUrl() { + val url = urlText.trim() + if (url.isEmpty()) { + loadError = "请填写 URL" + return + } + loading = true + importJob.set(scope.launch { + try { + val result = withContext(Dispatchers.IO) { + runCatching { + val conn = URL(url).openConnection() as HttpURLConnection + importConnection.set(conn) + conn.instanceFollowRedirects = true + conn.connectTimeout = 15_000 + conn.readTimeout = 20_000 + conn.inputStream.bufferedReader().use { it.readText() } + } + } + result.fold( + onSuccess = { finishLoad(it) }, + onFailure = { loadError = "从 URL 读取失败:${it.message}" }, + ) + } finally { + importConnection.getAndSet(null)?.disconnect() + loading = false + } + }) + } + + val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + loading = true + importJob.set(scope.launch { + try { + val result = withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.openInputStream(uri) + ?.bufferedReader() + ?.use { it.readText() } + ?: error("无法打开文件") + } + } + result.fold( + onSuccess = { finishLoad(it) }, + onFailure = { loadError = "读取文件失败:${it.message}" }, + ) + } finally { + loading = false + } + }) + } + } + val trimmedId = id.trim() val idError = when { trimmedId.isEmpty() -> "ID 不能为空" @@ -331,7 +440,7 @@ private fun UploadEditScreen( Scaffold( topBar = { TopAppBar( - title = { Text(if (initial == null) "添加上传配置" else "编辑上传配置") }, + title = { Text(if (isNew) "添加上传配置" else "编辑上传配置") }, navigationIcon = { IconButton(onClick = onCancel) { Icon( @@ -499,11 +608,7 @@ private fun UploadEditScreen( Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedButton( - onClick = { - importText = "" - importError = null - importDialog = true - }, + onClick = { showImportMenu = true }, modifier = Modifier .weight(1f) .height(40.dp), @@ -566,14 +671,6 @@ private fun UploadEditScreen( modifier = Modifier .fillMaxWidth() .heightIn(min = 160.dp), - placeholder = { - Text( - text = "{\"type\":\"CUSTOM\",\"id\":\"my-upload\"," + - "\"uploadUrl\":{\"mode\":\"general\",\"url\":\"https://example.com/upload\"}," + - "\"headers\":{}}", - style = MaterialTheme.typography.bodySmall - ) - }, textStyle = MaterialTheme.typography.bodySmall ) importError?.let { @@ -625,6 +722,93 @@ private fun UploadEditScreen( ) } + if (showImportMenu) { + AlertDialog( + onDismissRequest = { showImportMenu = false }, + title = { Text("导入 JSON") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + ImportAction("从 URL 导入") { + showImportMenu = false + urlText = "" + showUrlDialog = true + } + ImportAction("选择文件") { + showImportMenu = false + filePicker.launch( + arrayOf("application/json", "text/plain", "application/octet-stream") + ) + } + ImportAction("打开编辑器") { + showImportMenu = false + openJsonEditor("") + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = { showImportMenu = false }) { Text("取消") } + } + ) + } + + if (showUrlDialog) { + AlertDialog( + onDismissRequest = { showUrlDialog = false }, + title = { Text("从 URL 导入") }, + text = { + OutlinedTextField( + value = urlText, + onValueChange = { urlText = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("URL") }, + placeholder = { Text("") }, + textStyle = MaterialTheme.typography.bodySmall + ) + }, + confirmButton = { + TextButton(onClick = { + showUrlDialog = false + loadFromUrl() + }) { Text("导入") } + }, + dismissButton = { + TextButton(onClick = { showUrlDialog = false }) { Text("取消") } + } + ) + } + + if (loading) { + AlertDialog( + onDismissRequest = { cancelImport() }, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = false, + ), + title = { Text("正在导入") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + }, + confirmButton = { + TextButton(onClick = { cancelImport() }) { Text("取消") } + } + ) + } + + loadError?.let { message -> + AlertDialog( + onDismissRequest = { loadError = null }, + title = { Text("加载失败") }, + text = { Text(message) }, + confirmButton = { + TextButton(onClick = { loadError = null }) { Text("确定") } + } + ) + } + if (exportDialog) { val json = runCatching { UploadStore.encodeProfile(buildProfile()).toString(2) diff --git a/app/src/main/java/net/bluemangoo/indpjsk/upload/UploadStore.kt b/app/src/main/java/net/bluemangoo/indpjsk/upload/UploadStore.kt index 2226c75..20f8d4e 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/upload/UploadStore.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/upload/UploadStore.kt @@ -36,6 +36,8 @@ object UploadStore { private const val KEY_STEPS = "steps" private const val KEY_HOSTS = "hosts" private const val KEY_TARGET = "target" + private const val KEY_UPLOAD = "upload" + private const val KEY_IGNORE = "ignore" private const val KEY_SUITE = "suite" private const val KEY_MYSEKAI = "mysekai" @@ -79,7 +81,7 @@ object UploadStore { val obj = item.optJSONObject(KEY_PROFILE) ?: continue val profile = decodeProfile(obj) if (profile == null) { - VpnLog.warn("跳过无法识别的上传配置: ${obj.optString(KEY_TYPE)}") + VpnLog.warn("跳过无法识别的上传配置: ${obj.textOrNull(KEY_TYPE).orEmpty()}") continue } out += UploadEntry(item.optBoolean(KEY_ENABLED, false), profile) @@ -103,12 +105,14 @@ object UploadStore { } fun decodeProfile(obj: JSONObject): UploadProfile? { - val id = obj.optString(KEY_ID) - val type = UploadProfileType.entries.firstOrNull { it.name == obj.optString(KEY_TYPE) } + val id = obj.textOrNull(KEY_ID).orEmpty() + val typeName = obj.textOrNull(KEY_TYPE).orEmpty() + val type = UploadProfileType.entries.firstOrNull { it.name == typeName } ?: return null + val uploadCode = obj.textOrNull(KEY_UPLOAD_CODE).orEmpty() return when (type) { - UploadProfileType.HARUKI -> UploadProfileHaruki(id, obj.optString(KEY_UPLOAD_CODE)) - UploadProfileType.REASONA -> UploadProfileReasona(id, obj.optString(KEY_UPLOAD_CODE)) + UploadProfileType.HARUKI -> UploadProfileHaruki(id, uploadCode) + UploadProfileType.REASONA -> UploadProfileReasona(id, uploadCode) UploadProfileType._8823 -> UploadProfile8823(id) UploadProfileType.CUSTOM -> UploadProfileCustom( id = id, @@ -125,58 +129,126 @@ object UploadStore { } fun encodeMixin(mixin: UploadMixin): JSONObject = JSONObject().apply { - put(KEY_PROFILES, JSONArray().apply { - mixin.profiles.forEach { profile -> - put(JSONObject().apply { - put(KEY_NAME, profile.name) - put(KEY_STEPS, JSONArray().apply { - profile.steps.forEach { step -> - put(JSONObject().apply { - put(KEY_NAME, step.name) - put(KEY_HOSTS, JSONArray(step.hosts)) - put(KEY_TARGET, step.target) - }) - } + if (mixin.profiles.isNotEmpty()) { + put(KEY_PROFILES, JSONArray().apply { + mixin.profiles.forEach { profile -> + put(JSONObject().apply { + put(KEY_NAME, profile.name) + put(KEY_STEPS, JSONArray().apply { + profile.steps.forEach { step -> + put(JSONObject().apply { + put(KEY_NAME, step.name) + put(KEY_HOSTS, JSONArray(step.hosts)) + put(KEY_TARGET, step.target) + }) + } + }) }) - }) - } - }) + } + }) + } + mixin.upload?.let { put(KEY_UPLOAD, encodeUpload(it)) } } fun decodeMixin(obj: JSONObject?): UploadMixin? { if (obj == null) return null - val profileArray = obj.optJSONArray(KEY_PROFILES) ?: return null + val profiles = mutableListOf() - for (i in 0 until profileArray.length()) { - val profileObj = profileArray.optJSONObject(i) ?: continue - val name = profileObj.optString(KEY_NAME).trim() - if (name.isEmpty()) continue + val profileArray = obj.optJSONArray(KEY_PROFILES) + if (profileArray != null) { + for (i in 0 until profileArray.length()) { + val profileObj = profileArray.optJSONObject(i) ?: continue + val name = profileObj.textOrNull(KEY_NAME).orEmpty().trim() + if (name.isEmpty()) continue - val steps = mutableListOf() - val stepArray = profileObj.optJSONArray(KEY_STEPS) ?: JSONArray() - for (j in 0 until stepArray.length()) { - val stepObj = stepArray.optJSONObject(j) ?: continue - val stepName = stepObj.optString(KEY_NAME).trim() - if (stepName.isEmpty()) continue + val steps = mutableListOf() + val stepArray = profileObj.optJSONArray(KEY_STEPS) ?: JSONArray() + for (j in 0 until stepArray.length()) { + val stepObj = stepArray.optJSONObject(j) ?: continue + val stepName = stepObj.textOrNull(KEY_NAME).orEmpty().trim() + if (stepName.isEmpty()) continue - val hosts = mutableListOf() - val hostArray = stepObj.optJSONArray(KEY_HOSTS) ?: JSONArray() - for (k in 0 until hostArray.length()) { - val host = hostArray.optString(k).trim() - if (host.isNotEmpty()) hosts.add(host) - } + val hosts = mutableListOf() + val hostArray = stepObj.optJSONArray(KEY_HOSTS) ?: JSONArray() + for (k in 0 until hostArray.length()) { + val host = hostArray.textOrNull(k).orEmpty().trim() + if (host.isNotEmpty()) hosts.add(host) + } - steps.add( - UploadMixinStep( - name = stepName, - hosts = hosts, - target = stepObj.optString(KEY_TARGET).trim(), + steps.add( + UploadMixinStep( + name = stepName, + hosts = hosts, + target = stepObj.textOrNull(KEY_TARGET).orEmpty().trim(), + ) ) - ) + } + profiles.add(UploadMixinProfile(name, steps)) } - profiles.add(UploadMixinProfile(name, steps)) } - return UploadMixin(profiles) + + val upload = decodeUpload(obj.optJSONObject(KEY_UPLOAD)) + if (profiles.isEmpty() && upload == null) return null + return UploadMixin(profiles, upload) + } + + private fun encodeUpload(upload: UploadMixinUpload): JSONObject = JSONObject().apply { + when { + upload.ignoreAll -> put(KEY_IGNORE, "*") + upload.ignore.isNotEmpty() -> put(KEY_IGNORE, JSONArray().apply { + upload.ignore.forEach { item -> + if (item.steps.isEmpty()) { + put(item.region) + } else { + put(JSONObject().apply { + put(KEY_NAME, item.region) + put(KEY_STEPS, JSONArray(item.steps)) + }) + } + } + }) + } + } + + private fun decodeUpload(obj: JSONObject?): UploadMixinUpload? { + if (obj == null) return null + @Suppress("UNEXHAUSTIVE_WHEN_BASED_ON_JAVA_ANNOTATIONS") + return when (val raw = obj.opt(KEY_IGNORE)) { + is String -> when { + raw == "*" -> UploadMixinUpload(ignoreAll = true) + raw.isBlank() -> null + else -> UploadMixinUpload(ignore = listOf(UploadIgnoreProfile(raw.trim()))) + } + + is JSONArray -> { + val items = mutableListOf() + for (i in 0 until raw.length()) { + when (val item = raw.opt(i)) { + is String -> { + if (item.isNotBlank()) { + items.add(UploadIgnoreProfile(item.trim())) + } + } + + is JSONObject -> { + val region = item.textOrNull(KEY_NAME).orEmpty().trim() + if (region.isEmpty()) continue + val steps = mutableListOf() + val stepArray = item.optJSONArray(KEY_STEPS) ?: JSONArray() + for (j in 0 until stepArray.length()) { + val step = stepArray.textOrNull(j).orEmpty().trim() + if (step.isNotEmpty()) steps.add(step) + } + items.add(UploadIgnoreProfile(region, steps)) + } + + } + } + UploadMixinUpload(ignore = items) + } + + else -> null + } } private fun encodeUrl(url: UploadUrl): JSONObject = when (url) { @@ -195,19 +267,24 @@ object UploadStore { private fun decodeUrl(obj: JSONObject?): UploadUrl { if (obj == null) return UploadUrlGeneral("") - return when (obj.optString(KEY_MODE, URL_MODE_GENERAL)) { + val mode = obj.textOrNull(KEY_MODE)?.trim()?.takeIf { it.isNotEmpty() } ?: URL_MODE_GENERAL + return when (mode) { URL_MODE_EACH -> UploadUrlEach( suite = obj.optUrlOrNull(KEY_SUITE), mysekai = obj.optUrlOrNull(KEY_MYSEKAI), birthday = obj.optUrlOrNull(KEY_BIRTHDAY), ) - else -> UploadUrlGeneral(obj.optString(KEY_URL)) + else -> UploadUrlGeneral( + obj.textOrNull(KEY_URL)?.trim()?.takeIf { + it.isNotEmpty() && !it.equals("null", ignoreCase = true) + } ?: "" + ) } } private fun JSONObject.optUrlOrNull(key: String): String? = - optString(key).trim().takeIf { it.isNotEmpty() } + textOrNull(key)?.trim()?.takeIf { it.isNotEmpty() && !it.equals("null", ignoreCase = true) } private fun decodeHeaders(obj: JSONObject?): Map { if (obj == null) return emptyMap() @@ -215,8 +292,14 @@ object UploadStore { val keys = obj.keys() while (keys.hasNext()) { val key = keys.next() - map[key] = obj.optString(key) + map[key] = obj.textOrNull(key) ?: continue } return map } + + private fun JSONObject.textOrNull(key: String): String? = + if (isNull(key)) null else optString(key) + + private fun JSONArray.textOrNull(index: Int): String? = + if (isNull(index)) null else optString(index) } diff --git a/app/src/main/java/net/bluemangoo/indpjsk/upload/Uploader.kt b/app/src/main/java/net/bluemangoo/indpjsk/upload/Uploader.kt index 9e16ccd..2797708 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/upload/Uploader.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/upload/Uploader.kt @@ -21,16 +21,25 @@ object Uploader { val entries = UploadStore.load() .filter { it.enabled } .filter { onlyProfileIds == null || it.profile.id in onlyProfileIds } + .filter { entry -> + val definedHere = + entry.profile.id in result.step.explicitUploadIds || + (onlyProfileIds != null && entry.profile.id in onlyProfileIds) + if (definedHere) { + true + } else { + val upload = (entry.profile as? UploadProfileCustom)?.mixin?.upload + upload == null || !upload.ignores(result.profile.id, result.step.uploadType) + } + } if (entries.isEmpty()) return - VpnLog.info("${result.step.name} 开始上传(${entries.size} 个配置,串行)") + VpnLog.info("${result.step.name} 开始上传(${entries.size} 个配置)") for ((_, profile) in entries) { try { val uploaded = uploadChunked(profile, result.url, result.file, result.step.uploadType) if (uploaded) { VpnLog.success("[${profile.id}] ${result.step.name} 上传完成") - } else { - VpnLog.info("[${profile.id}] ${result.step.name} 没有对应的上传地址,跳过") } } catch (c: CancellationException) { throw c @@ -166,7 +175,8 @@ enum class UploadProfileType(val displayName: String) { /** mixin:在自定义上传配置里顺带定义抓包配置。 */ data class UploadMixin( - val profiles: List, + val profiles: List = emptyList(), + val upload: UploadMixinUpload? = null, ) data class UploadMixinProfile( @@ -176,7 +186,7 @@ data class UploadMixinProfile( /** * name: 步骤显示名(同一 profile 内同名步骤会合并) - * hosts: 域名列表,可以填 `cn` 这样的预设别名 + * hosts: 域名列表,可以填 `cn` / `jp` / `en` / `tw` / `kr` 这样的预设别名 * target: `suite` / `mysekai` / `birthday` 快捷方式,或直接填请求路径正则 */ data class UploadMixinStep( @@ -185,6 +195,32 @@ data class UploadMixinStep( val target: String, ) +/** + * `upload.ignore`:这个上传配置对哪些"不是自己 mixin 定义"的步骤不生效。 + * 自己 mixin 定义出来的步骤永远会上传(见 [ignores] 的调用处)。 + */ +data class UploadMixinUpload( + /** ignore: "*" */ + val ignoreAll: Boolean = false, + val ignore: List = emptyList(), +) { + fun ignores(region: String, stepType: String?): Boolean { + if (ignoreAll) return true + return ignore.any { item -> + item.region.equals(region, ignoreCase = true) && + (item.steps.isEmpty() || + (stepType != null && item.steps.any { it.equals(stepType, ignoreCase = true) })) + } + } +} + +data class UploadIgnoreProfile( + /** Region:cn / jp / en / tw / kr */ + val region: String, + /** 空表示整个配置 */ + val steps: List = emptyList(), +) + sealed interface UploadProfile { val id: String val type: UploadProfileType @@ -266,10 +302,10 @@ class UploadProfileReasona(override val id: String, val uploadCode: String) : Up get() { val payload = parseJwt() ?: return emptyMap() val map = mutableMapOf() - if (payload.has("qqnum")) { + if (payload.has("qqnum") && !payload.isNull("qqnum")) { map["QQ 号"] = payload.optString("qqnum") } - if (payload.has("sync_8823")) { + if (payload.has("sync_8823") && !payload.isNull("sync_8823")) { map["同步给 8823"] = payload.optBoolean("sync_8823").toString() } return map diff --git a/app/src/main/java/net/bluemangoo/indpjsk/vpn/CaptureVpnService.kt b/app/src/main/java/net/bluemangoo/indpjsk/vpn/CaptureVpnService.kt index 676aa8a..e700045 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/vpn/CaptureVpnService.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/vpn/CaptureVpnService.kt @@ -123,12 +123,11 @@ class CaptureVpnService : VpnService() { val latch = CountDownLatch(1) allDone = latch - // 持续运行:抓到后不自动停,并且同一类型的包可以反复抓(Rust 侧 waiter 不摘除) val continueRun = SettingsStore.continueRun VpnLog.info("==== 开始抓包 ====") VpnLog.info("配置: ${profile.name}") - VpnLog.info("步骤(并行): ${enabledSteps.joinToString(" / ") { it.name }}") + VpnLog.info("启用: ${enabledSteps.joinToString(" / ") { it.name }}") if (continueRun) VpnLog.info("持续运行: 开启(抓到后不会自动停止)") VpnBridge.stepListener = { stepId, url -> @@ -254,7 +253,6 @@ class CaptureVpnService : VpnService() { VpnLog.success("${step.name} 抓到目标请求") val result = CaptureResult(profile, step, url, target) - VpnLog.info("${step.name} 立即执行后处理(不等其他步骤)") postScope.launch { try { step.postAction(result) diff --git a/core_vpn/src/mitm.rs b/core_vpn/src/mitm.rs index 3ccd7d9..c687d97 100644 --- a/core_vpn/src/mitm.rs +++ b/core_vpn/src/mitm.rs @@ -24,6 +24,15 @@ use tun_rs::BorrowedAsyncDevice; static ROOT_STORE: OnceLock> = OnceLock::new(); +const BLOCKED_HOSTS: &[&str] = &["submit.backtrace.io"]; + +fn is_blocked_host(host: &str) -> bool { + let host = host.trim_end_matches('.'); + BLOCKED_HOSTS + .iter() + .any(|blocked| host.eq_ignore_ascii_case(blocked)) +} + const SNIFF_TIMEOUT: Duration = Duration::from_secs(5); const TEARDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(3); const TEARDOWN_SETTLE: Duration = Duration::from_millis(50); @@ -136,6 +145,14 @@ where return passthrough_cancel_wrapped(io, dst, None, upstream, &cancel).await; }; + // 崩溃上报之类的域名直接拒绝,不建立上游连接 + if let Some(host) = sni.as_deref() { + if is_blocked_host(host) { + warn!("blocked host {host} ({dst:?}), connection rejected"); + return Ok(()); + } + } + let host_matched = sni .as_deref() .map(|s| target_hosts.iter().any(|h| s.eq_ignore_ascii_case(h))) diff --git a/upload-config.d.ts b/upload-config.d.ts index 29dca8b..b354e2f 100644 --- a/upload-config.d.ts +++ b/upload-config.d.ts @@ -1,5 +1,8 @@ // noinspection JSUnusedGlobalSymbols +type Region = "cn" | "jp" | "en" | "tw" | "kr"; +type Step = "suite" | "mysekai" | "birthday"; + interface UploadUrlGeneral { mode: "general"; url: string; @@ -14,8 +17,8 @@ interface UploadUrlEach { interface UploadMixinStep { name: string; - hosts: ("cn" | "jp" | "en" | "tw" | "kr" | string)[]; - target: "suite" | "mysekai" | "birthday" | "" | string; + hosts: (Region | string)[]; + target: Step | string; } interface UploadMixinProfile { @@ -23,8 +26,18 @@ interface UploadMixinProfile { steps: UploadMixinStep[]; } +interface UploadMixinUploadIgnoreProfile { + name: Region; + steps: Step[]; +} + +interface UploadMixinUpload { + ignore?: "*" | (Region | UploadMixinUploadIgnoreProfile)[]; // never ignore defined profile in mixin context +} + interface UploadMixin { - profiles: UploadMixinProfile[]; + profiles?: UploadMixinProfile[]; + upload?: UploadMixinUpload; } export interface UploadProfileCustom {