From 5739f7f8e781c1fae8d284047186e817b9f83d90 Mon Sep 17 00:00:00 2001 From: Bluemangoo Date: Wed, 28 Oct 2026 13:51:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=8D=E7=94=A8=E8=BF=9E=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- app/build.gradle.kts | 4 +- .../net/bluemangoo/indpjsk/MainActivity.kt | 8 +- .../indpjsk/data/PredefinedProfiles.kt | 7 +- .../bluemangoo/indpjsk/data/SettingsStore.kt | 26 +- .../bluemangoo/indpjsk/ui/CaptureScreen.kt | 50 +--- .../indpjsk/ui/ProxySettingsScreen.kt | 34 ++- .../indpjsk/ui/UploadSettingsScreen.kt | 95 +++++++ .../net/bluemangoo/indpjsk/upload/Uploader.kt | 15 +- .../indpjsk/vpn/CaptureVpnService.kt | 254 +++++++++--------- .../net/bluemangoo/indpjsk/vpn/VpnBridge.kt | 44 +-- core_vpn/Cargo.lock | 2 + core_vpn/Cargo.toml | 2 + core_vpn/src/lib.rs | 216 ++++++++++----- core_vpn/src/mitm.rs | 196 +++++++------- 15 files changed, 581 insertions(+), 376 deletions(-) create mode 100644 app/src/main/java/net/bluemangoo/indpjsk/ui/UploadSettingsScreen.kt diff --git a/README.md b/README.md index a747d08..ef0be65 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ Rootless 开箱即用的 Project Sekai 用户数据抓包工具,支持上传 ## 使用 - 安装并打开应用 -- 如果需要上传,请在 [Haruki 工具箱](https://haruki.seiunx.com/upload-data) 中的 `iOS 模块` 方式里复制上传码并粘贴到应用中 +- 如果需要上传,请在 [Haruki 工具箱](https://haruki.seiunx.com/upload-data) 中的 `iOS 模块` 方式里复制上传码,粘贴到右上角菜单的「上传设置」界面,并勾选「上传到 Haruki」 - 在右上角选择需要的配置 - 可以取消勾选不需要的抓包步骤 - 点击开始,如果跳出建立 VPN 的提示,请允许 -- 启动游戏,进入游戏触发需要抓的包,应用会自动抓取数据并自动结束,如果配置了上传会自动上传 +- 启动游戏,进入游戏触发需要抓的包,应用会自动抓取数据并自动结束;每一步抓到后就会立刻执行该步的后处理(勾选了上传就会马上上传,不用等所有步骤) ## 触发位置 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 79bb4e6..4d5ca69 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 = 3 - versionName = "1.2" + versionCode = 4 + versionName = "1.3" } diff --git a/app/src/main/java/net/bluemangoo/indpjsk/MainActivity.kt b/app/src/main/java/net/bluemangoo/indpjsk/MainActivity.kt index e0904fd..fc108ed 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/MainActivity.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/MainActivity.kt @@ -25,10 +25,11 @@ import net.bluemangoo.indpjsk.ui.CaptureScreen import net.bluemangoo.indpjsk.ui.CaptureViewModel import net.bluemangoo.indpjsk.ui.CapturedFilesScreen import net.bluemangoo.indpjsk.ui.ProxySettingsScreen +import net.bluemangoo.indpjsk.ui.UploadSettingsScreen import net.bluemangoo.indpjsk.ui.theme.IndPjskTheme import java.io.File -enum class AppScreen { MAIN, PROXY, APP_SELECTION, FILES, ABOUT } +enum class AppScreen { MAIN, PROXY, UPLOAD, APP_SELECTION, FILES, ABOUT } class MainActivity : ComponentActivity() { @@ -73,10 +74,15 @@ class MainActivity : ComponentActivity() { onStartCapture = { requestVpnAndStart() }, onStopCapture = { viewModel.stopCapture(this) }, onOpenProxySettings = { screen = AppScreen.PROXY }, + onOpenUploadSettings = { screen = AppScreen.UPLOAD }, onOpenFiles = { screen = AppScreen.FILES }, onOpenAbout = { screen = AppScreen.ABOUT }, ) + AppScreen.UPLOAD -> UploadSettingsScreen( + onBack = { screen = AppScreen.MAIN }, + ) + AppScreen.PROXY -> ProxySettingsScreen( bypassPackages = bypassPackages, onBypassChange = { newSet -> diff --git a/app/src/main/java/net/bluemangoo/indpjsk/data/PredefinedProfiles.kt b/app/src/main/java/net/bluemangoo/indpjsk/data/PredefinedProfiles.kt index 5348de0..94914b4 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/data/PredefinedProfiles.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/data/PredefinedProfiles.kt @@ -3,7 +3,7 @@ package net.bluemangoo.indpjsk.data import net.bluemangoo.indpjsk.upload.Uploader object PredefinedProfiles { - private const val SUITE_PATH = "/api/suite/user/(\\d+)(\\?isLogin=true)?\$" + private const val SUITE_PATH = "/api/suite/user/(\\d+)(\\?isLogin=true)?$" private const val MYSEKAI_PATH = "/api/user/(\\d+)/mysekai\\?isForceAllReloadOnlyMysekai=True" @@ -40,7 +40,10 @@ object PredefinedProfiles { } ) - private val CN_HOSTS = listOf("mkcn-prod-public-60001-1.dailygn.com") + private val CN_HOSTS = listOf( + "mkcn-prod-public-60001-1.dailygn.com", + "mkcn-prod-public-60001-2.dailygn.com" + ) private val JP_HOSTS = listOf("production-game-api.sekai.colorfulpalette.org") private val EN_HOSTS = listOf("n-production-game-api.sekai-en.com") private val TW_HOSTS = listOf( diff --git a/app/src/main/java/net/bluemangoo/indpjsk/data/SettingsStore.kt b/app/src/main/java/net/bluemangoo/indpjsk/data/SettingsStore.kt index 4181959..509a7d0 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/data/SettingsStore.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/data/SettingsStore.kt @@ -3,19 +3,19 @@ package net.bluemangoo.indpjsk.data import android.content.Context import androidx.core.content.edit -/** 全局配置持久化:上传码、上游代理、忽略的应用、选中的配置 */ object SettingsStore { private const val PREFS = "settings" private const val KEY_UPLOAD_CODE = "upload_code" + private const val KEY_USE_UPLOAD = "use_upload" private const val KEY_PROXY = "upstream_proxy" + private const val KEY_USE_PROXY = "use_proxy" private const val KEY_BYPASS = "bypass_packages" private const val KEY_PROFILE = "selected_profile" private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - // ---- 上传码 ---- fun loadUploadCode(context: Context): String = prefs(context).getString(KEY_UPLOAD_CODE, "").orEmpty() @@ -23,7 +23,15 @@ object SettingsStore { prefs(context).edit { putString(KEY_UPLOAD_CODE, code) } } - // ---- 上游代理(空 = 直连) ---- + fun loadUseUpload(context: Context): Boolean { + return loadUploadCode(context).isNotBlank() && prefs(context).getBoolean(KEY_USE_UPLOAD, true) + } + + fun saveUseUpload(context: Context, enabled: Boolean) { + prefs(context).edit { putBoolean(KEY_USE_UPLOAD, enabled) } + } + + fun loadProxy(context: Context): String = prefs(context).getString(KEY_PROXY, "").orEmpty() @@ -31,7 +39,15 @@ object SettingsStore { prefs(context).edit { putString(KEY_PROXY, proxy.trim()) } } - // ---- 不走 VPN 的应用(包名) ---- + fun loadUseProxy(context: Context): Boolean { + return loadProxy(context).isNotBlank() && prefs(context).getBoolean(KEY_USE_PROXY, true) + } + + fun saveUseProxy(context: Context, enabled: Boolean) { + prefs(context).edit { putBoolean(KEY_USE_PROXY, enabled) } + } + + fun loadBypassPackages(context: Context): Set = prefs(context).getStringSet(KEY_BYPASS, emptySet())?.toSet() ?: emptySet() @@ -39,7 +55,7 @@ object SettingsStore { prefs(context).edit { putStringSet(KEY_BYPASS, packages) } } - // ---- 当前选中的配置 ---- + fun loadSelectedProfileId(context: Context): String? = prefs(context).getString(KEY_PROFILE, null) diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureScreen.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureScreen.kt index c800b07..1beb450 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureScreen.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureScreen.kt @@ -15,14 +15,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import net.bluemangoo.indpjsk.R import net.bluemangoo.indpjsk.data.CaptureProfile import net.bluemangoo.indpjsk.data.CaptureProfileStep -import net.bluemangoo.indpjsk.data.SettingsStore import net.bluemangoo.indpjsk.data.StepSelectionStore import net.bluemangoo.indpjsk.state.CaptureStatus import net.bluemangoo.indpjsk.vpn.LogEntry @@ -36,6 +34,7 @@ fun CaptureScreen( onStartCapture: () -> Unit, onStopCapture: () -> Unit, onOpenProxySettings: () -> Unit, + onOpenUploadSettings: () -> Unit, onOpenFiles: () -> Unit, onOpenAbout: () -> Unit, ) { @@ -46,7 +45,6 @@ fun CaptureScreen( var enabledMap by remember { mutableStateOf(StepSelectionStore.loadAll(context) as Map) } var menuExpanded by remember { mutableStateOf(false) } var profileExpanded by remember { mutableStateOf(false) } - var showUploadCodeDialog by remember { mutableStateOf(false) } val isRunning = state.status == CaptureStatus.RUNNING val isCaptured = state.status == CaptureStatus.CAPTURED @@ -69,10 +67,10 @@ fun CaptureScreen( onDismissRequest = { menuExpanded = false } ) { DropdownMenuItem( - text = { Text("上传码") }, + text = { Text("上传设置") }, onClick = { menuExpanded = false - showUploadCodeDialog = true + onOpenUploadSettings() } ) DropdownMenuItem( @@ -215,48 +213,6 @@ fun CaptureScreen( } } } - - if (showUploadCodeDialog) { - UploadCodeDialog(onDismiss = { showUploadCodeDialog = false }) - } -} - -@Composable -private fun UploadCodeDialog(onDismiss: () -> Unit) { - val context = LocalContext.current - var text by remember { mutableStateOf(SettingsStore.loadUploadCode(context)) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("上传码") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = text, - onValueChange = { text = it.trim() }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - label = { Text("Haruki 上传码") }, - visualTransformation = PasswordVisualTransformation(), - textStyle = MaterialTheme.typography.bodyMedium - ) - Text( - text = "从 Haruki 工具箱 - 数据上传 - iOS 获取。", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - confirmButton = { - TextButton(onClick = { - SettingsStore.saveUploadCode(context, text) - onDismiss() - }) { Text("保存") } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("取消") } - } - ) } @Composable diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/ProxySettingsScreen.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/ProxySettingsScreen.kt index 6bd2377..161945d 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/ui/ProxySettingsScreen.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/ProxySettingsScreen.kt @@ -29,6 +29,7 @@ fun ProxySettingsScreen( ) { val context = LocalContext.current var proxy by remember { mutableStateOf(SettingsStore.loadProxy(context)) } + var useProxy by remember { mutableStateOf(SettingsStore.loadUseProxy(context)) } var pendingRemove by remember { mutableStateOf(null) } val entries = remember(bypassPackages) { @@ -37,6 +38,7 @@ fun ProxySettingsScreen( val looksValid = proxy.isBlank() || ((proxy.startsWith("http://") || proxy.startsWith("https://")) && proxy.contains(":")) + val canUseProxy = proxy.isNotBlank() && looksValid Scaffold( topBar = { @@ -66,11 +68,35 @@ fun ProxySettingsScreen( ) { Text("上游代理", style = MaterialTheme.typography.labelLarge) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = useProxy && canUseProxy, + onCheckedChange = { + useProxy = it + SettingsStore.saveUseProxy(context, it) + }, + enabled = canUseProxy + ) + Text( + text = "使用上游代理", + style = MaterialTheme.typography.bodyMedium + ) + } + OutlinedTextField( value = proxy, onValueChange = { proxy = it SettingsStore.saveProxy(context, it) + // 地址被清空时不允许再勾着;顺手把偏好也清掉,避免重新填写时自动勾上 + if (it.isBlank() && useProxy) { + useProxy = false + SettingsStore.saveUseProxy(context, false) + } }, modifier = Modifier.fillMaxWidth(), singleLine = true, @@ -79,10 +105,10 @@ fun ProxySettingsScreen( isError = !looksValid, supportingText = { Text( - text = if (looksValid) { - "留空以忽略;只支持 http / https 代理" - } else { - "格式示例:http://127.0.0.1:7890" + text = when { + proxy.isBlank() -> "填写地址后才能勾选启用;只支持 http / https 代理" + !looksValid -> "格式示例:http://127.0.0.1:7890" + else -> "只支持 http / https 代理" } ) } diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/UploadSettingsScreen.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/UploadSettingsScreen.kt new file mode 100644 index 0000000..ad04fa0 --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/UploadSettingsScreen.kt @@ -0,0 +1,95 @@ +package net.bluemangoo.indpjsk.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import net.bluemangoo.indpjsk.R +import net.bluemangoo.indpjsk.data.SettingsStore + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UploadSettingsScreen(onBack: () -> Unit) { + val context = LocalContext.current + var code by remember { mutableStateOf(SettingsStore.loadUploadCode(context)) } + var useUpload by remember { mutableStateOf(SettingsStore.loadUseUpload(context)) } + var showCode by remember { mutableStateOf(false) } + + val canUpload = code.isNotBlank() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("上传设置") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(R.drawable.arrow_back_24px), + contentDescription = "返回" + ) + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = code, + onValueChange = { + code = it.trim() + SettingsStore.saveUploadCode(context, code) + if (code.isBlank() && useUpload) { + useUpload = false + SettingsStore.saveUseUpload(context, false) + } + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Haruki 上传码") }, + placeholder = { Text("从 Haruki 工具箱 - 数据上传 - iOS 获取") }, + visualTransformation = if (showCode) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + TextButton(onClick = { showCode = !showCode }) { + Text(if (showCode) "隐藏" else "显示") + } + } + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = useUpload && canUpload, + onCheckedChange = { + useUpload = it + SettingsStore.saveUseUpload(context, it) + }, + enabled = canUpload + ) + Text( + text = "上传到 Haruki", + style = MaterialTheme.typography.bodyMedium + ) + } + } + } +} 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 0677586..92b48a0 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/upload/Uploader.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/upload/Uploader.kt @@ -59,12 +59,17 @@ object Uploader { } suspend fun tryUpload(result: CaptureResult) { - val uploadCode = SettingsStore.loadUploadCode(App.appContext).trim() - if (uploadCode.isNotEmpty()) { - VpnLog.info("${result.profile.name}${result.step.name} 开始上传") - upload(uploadCode, result.url, result.file) - VpnLog.success("${result.profile.name}${result.step.name} 上传完成") + val context = App.appContext + // 复选框没勾就不传(没填上传码时复选框本身不可用) + if (!SettingsStore.loadUseUpload(context)) return + val uploadCode = SettingsStore.loadUploadCode(context).trim() + if (uploadCode.isEmpty()) { + VpnLog.warn("未填写上传码,跳过上传") + return } + VpnLog.info("${result.profile.name}${result.step.name} 开始上传") + upload(uploadCode, result.url, result.file) + VpnLog.success("${result.profile.name}${result.step.name} 上传完成") } private fun postChunk( 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 f1e286a..ed4f1bc 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/vpn/CaptureVpnService.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/vpn/CaptureVpnService.kt @@ -7,7 +7,11 @@ import android.os.ParcelFileDescriptor import kotlinx.coroutines.* import net.bluemangoo.indpjsk.data.* import net.bluemangoo.indpjsk.state.CaptureStateHolder +import org.json.JSONArray +import org.json.JSONObject import java.io.File +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch @SuppressLint("VpnServicePolicy") class CaptureVpnService : VpnService() { @@ -23,9 +27,7 @@ class CaptureVpnService : VpnService() { private val postScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) fun captureFile( - filesDir: File, - profile: CaptureProfile, - step: CaptureProfileStep + filesDir: File, profile: CaptureProfile, step: CaptureProfileStep ): File = File(File(File(filesDir, "captures"), profile.id), step.fileName) } @@ -38,6 +40,16 @@ class CaptureVpnService : VpnService() { @Volatile private var stopRequested = false + private val stepResults = ConcurrentHashMap() + + private val finishedSteps = ConcurrentHashMap.newKeySet() + + @Volatile + private var allDone: CountDownLatch? = null + + @Volatile + private var lastCaptured: File? = null + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { @@ -49,11 +61,13 @@ class CaptureVpnService : VpnService() { ACTION_STOP -> { stopRequested = true - VpnLog.warn("手动停止…") - VpnBridge.requestStop() - closeActivePfd() + VpnBridge.stopSession() + allDone?.countDown() CaptureStateHolder.setIdle() - stopSelf() + if (captureThread?.isAlive != true) { + closeActivePfd() + stopSelf() + } } else -> return START_NOT_STICKY @@ -66,7 +80,6 @@ class CaptureVpnService : VpnService() { activePfd = null try { pfd?.close() - VpnBridge.requestStop() } catch (t: Throwable) { VpnLog.warn("关闭代理失败: ${t.message}") } @@ -88,22 +101,47 @@ class CaptureVpnService : VpnService() { return } - val proxy = SettingsStore.loadProxy(this).trim() + val proxy: String? = if (SettingsStore.loadUseProxy(this)) { + SettingsStore.loadProxy(this).trim().takeIf { it.isNotEmpty() } + } else { + null + } val bypassPackages = SettingsStore.loadBypassPackages(this) - if (proxy.isNotEmpty()) VpnLog.info("上游代理: $proxy") + if (proxy != null) { + VpnLog.info("上游代理: $proxy") + } else { + VpnLog.info("未启用上游代理") + } if (bypassPackages.isNotEmpty()) { VpnLog.info("忽略的应用: ${bypassPackages.joinToString(", ")}") } stopRequested = false + stepResults.clear() + finishedSteps.clear() + lastCaptured = null + val latch = CountDownLatch(1) + allDone = latch + VpnLog.info("==== 开始抓包 ====") VpnLog.info("配置: ${profile.name}") - VpnLog.info("步骤: ${enabledSteps.joinToString(" -> ") { it.name }}") + VpnLog.info("步骤(并行): ${enabledSteps.joinToString(" / ") { it.name }}") + + VpnBridge.stepListener = { stepId, url -> + val step = enabledSteps.firstOrNull { it.id == stepId } + if (step == null) { + VpnLog.warn("收到未知步骤的结果: $stepId") + } else { + handleStepResult(profile, step, url) + finishedSteps.add(step.id) + if (finishedSteps.size >= enabledSteps.size) { + latch.countDown() + } + } + } captureThread = Thread({ var pfd: ParcelFileDescriptor? - var lastFile: File? = null - var completed = 0 var errorMsg: String? = null try { @@ -111,159 +149,131 @@ class CaptureVpnService : VpnService() { throw IllegalStateException("Rust 动态库 core_vpn 未加载") } - val builder = Builder() - .setSession("Capture-${profile.id}") - .addAddress(VPN_ADDRESS, VPN_PREFIX) - .addDisallowedApplication(packageName) - .addRoute("1.0.0.0", 8) + val builder = Builder().setSession("Capture-${profile.id}").addAddress(VPN_ADDRESS, VPN_PREFIX) + .addDisallowedApplication(packageName).addRoute("1.0.0.0", 8) - .addRoute("2.0.0.0", 7) - .addRoute("4.0.0.0", 6) - .addRoute("8.0.0.0", 7) + .addRoute("2.0.0.0", 7).addRoute("4.0.0.0", 6).addRoute("8.0.0.0", 7) - .addRoute("11.0.0.0", 8) - .addRoute("12.0.0.0", 6) - .addRoute("16.0.0.0", 4) - .addRoute("32.0.0.0", 3) - .addRoute("64.0.0.0", 5) - .addRoute("96.0.0.0", 10) + .addRoute("11.0.0.0", 8).addRoute("12.0.0.0", 6).addRoute("16.0.0.0", 4).addRoute("32.0.0.0", 3) + .addRoute("64.0.0.0", 5).addRoute("96.0.0.0", 10) - .addRoute("100.128.0.0", 9) - .addRoute("101.0.0.0", 8) - .addRoute("102.0.0.0", 7) - .addRoute("104.0.0.0", 5) - .addRoute("112.0.0.0", 4) + .addRoute("100.128.0.0", 9).addRoute("101.0.0.0", 8).addRoute("102.0.0.0", 7) + .addRoute("104.0.0.0", 5).addRoute("112.0.0.0", 4) - .addRoute("128.0.0.0", 3) - .addRoute("160.0.0.0", 5) - .addRoute("168.0.0.0", 6) - .addRoute("169.0.0.0", 9) - .addRoute("169.128.0.0", 10) - .addRoute("169.192.0.0", 11) - .addRoute("169.224.0.0", 12) - .addRoute("169.240.0.0", 13) - .addRoute("169.248.0.0", 14) - .addRoute("169.252.0.0", 15) + .addRoute("128.0.0.0", 3).addRoute("160.0.0.0", 5).addRoute("168.0.0.0", 6).addRoute("169.0.0.0", 9) + .addRoute("169.128.0.0", 10).addRoute("169.192.0.0", 11).addRoute("169.224.0.0", 12) + .addRoute("169.240.0.0", 13).addRoute("169.248.0.0", 14).addRoute("169.252.0.0", 15) - .addRoute("169.255.0.0", 16) - .addRoute("170.0.0.0", 7) - .addRoute("172.0.0.0", 12) + .addRoute("169.255.0.0", 16).addRoute("170.0.0.0", 7).addRoute("172.0.0.0", 12) - .addRoute("172.32.0.0", 11) - .addRoute("172.64.0.0", 10) - .addRoute("172.128.0.0", 9) - .addRoute("173.0.0.0", 8) - .addRoute("174.0.0.0", 7) - .addRoute("176.0.0.0", 4) - .addRoute("192.0.0.0", 9) - .addRoute("192.128.0.0", 11) - .addRoute("192.160.0.0", 13) - .addRoute("192.169.0.0", 16) - .addRoute("192.170.0.0", 15) - .addRoute("192.172.0.0", 14) - .addRoute("192.176.0.0", 12) + .addRoute("172.32.0.0", 11).addRoute("172.64.0.0", 10).addRoute("172.128.0.0", 9) + .addRoute("173.0.0.0", 8).addRoute("174.0.0.0", 7).addRoute("176.0.0.0", 4).addRoute("192.0.0.0", 9) + .addRoute("192.128.0.0", 11).addRoute("192.160.0.0", 13).addRoute("192.169.0.0", 16) + .addRoute("192.170.0.0", 15).addRoute("192.172.0.0", 14).addRoute("192.176.0.0", 12) .addRoute("192.192.0.0", 10) - .addRoute("193.0.0.0", 8) - .addRoute("194.0.0.0", 7) - .addRoute("196.0.0.0", 6) - .addRoute("200.0.0.0", 5) - .addRoute("208.0.0.0", 4) - .addRoute("::", 0) - .addDnsServer("8.8.8.8") - .addDnsServer("114.114.114.114") + .addRoute("193.0.0.0", 8).addRoute("194.0.0.0", 7).addRoute("196.0.0.0", 6).addRoute("200.0.0.0", 5) + .addRoute("208.0.0.0", 4).addRoute("::", 0).addDnsServer("8.8.8.8").addDnsServer("114.114.114.114") for (pkg in bypassPackages) { if (pkg == packageName) continue - runCatching { builder.addDisallowedApplication(pkg) } - .onFailure { VpnLog.warn("忽略应用 $pkg 失败: ${it.message}") } + runCatching { builder.addDisallowedApplication(pkg) }.onFailure { VpnLog.warn("忽略应用 $pkg 失败: ${it.message}") } } - pfd = builder.establish() - ?: throw IllegalStateException("VpnService.establish() 返回 null") + pfd = builder.establish() ?: throw IllegalStateException("VpnService.establish() 返回 null") activePfd = pfd val fd = pfd.fd - VpnLog.info("网络代理已连接") - for ((index, step) in enabledSteps.withIndex()) { - if (stopRequested) { - VpnLog.warn("用户停止,终止后续步骤") - break + val stepsJson = JSONArray().apply { + enabledSteps.forEach { step -> + put(JSONObject().apply { + put("id", step.id) + put("hosts", JSONArray(step.hosts)) + put("path", step.path) + put("savePath", captureFile(filesDir, profile, step).absolutePath) + }) } + }.toString() - val label = "[${index + 1}/${enabledSteps.size}] ${step.name}" - val target = captureFile(filesDir, profile, step) - target.parentFile?.mkdirs() - if (target.exists()) target.delete() + if (!VpnBridge.boot(fd, stepsJson, proxy)) { + throw IllegalStateException("tun core 启动失败") + } + VpnLog.info("VPN 已建立") - val capturedUrl = VpnBridge.runCapture( - fd, - step.hosts.joinToString(","), - step.path, - target.absolutePath, - proxy - ) + latch.await() - if (capturedUrl == null) { - if (stopRequested) { - VpnLog.warn("$label 被用户停止") - break - } - errorMsg = "$label 未抓到目标请求" - VpnLog.error(errorMsg) - break - } - VpnLog.info("$label 抓到目标请求: $capturedUrl") - if (!target.exists() || target.length() == 0L) { - errorMsg = "$label 返回了 URL 但文件为空" - VpnLog.error(errorMsg) - break - } - - lastFile = target - completed++ - VpnLog.success("$label 抓取成功") - - val result = CaptureResult(profile, step, capturedUrl, target) - postScope.launch { - try { - step.postAction(result) - } catch (c: CancellationException) { - VpnLog.warn("$label 后处理被取消: ${c.message}") - throw c - } catch (t: Throwable) { - VpnLog.error("$label 后处理失败: ${t.message}") - } - } + if (stopRequested) { + VpnLog.warn("用户停止,已抓到 ${stepResults.size}/${enabledSteps.size} 步") + } else if (stepResults.size < enabledSteps.size) { + errorMsg = "只抓到 ${stepResults.size}/${enabledSteps.size} 步" + VpnLog.error(errorMsg) } } catch (t: Throwable) { errorMsg = t.message ?: "抓包异常" VpnLog.error("抓包异常: ${t.message}") } finally { + VpnBridge.stepListener = null + VpnBridge.stopSession() + VpnBridge.shutdownSession() closeActivePfd() - VpnLog.info("==== 抓包结束($completed/${enabledSteps.size} 步完成)====") + VpnLog.info("==== 抓包结束(${stepResults.size}/${enabledSteps.size} 步完成)====") if (errorMsg != null) { CaptureStateHolder.setError(errorMsg) - } else if (completed == enabledSteps.size && lastFile != null) { - CaptureStateHolder.setCaptured(lastFile) + } else if (stepResults.size == enabledSteps.size && lastCaptured != null) { + CaptureStateHolder.setCaptured(lastCaptured!!) } else { CaptureStateHolder.setIdle() } + allDone = null stopSelf() } }, "capture-blocking-worker").apply { start() } } + private fun handleStepResult( + profile: CaptureProfile, + step: CaptureProfileStep, + url: String?, + ) { + val target = captureFile(filesDir, profile, step) + if (url == null || !target.exists() || target.length() == 0L) { + VpnLog.error("${step.name} 抓取失败") + return + } + + stepResults[step.id] = url + lastCaptured = target + VpnLog.success("${step.name} 抓到目标请求") + + val result = CaptureResult(profile, step, url, target) + VpnLog.info("${step.name} 立即执行后处理(不等其他步骤)") + postScope.launch { + try { + step.postAction(result) + } catch (c: CancellationException) { + VpnLog.warn("${step.name} 后处理被取消: ${c.message}") + throw c + } catch (t: Throwable) { + VpnLog.error("${step.name} 后处理失败: ${t.message}") + } + } + } + override fun onRevoke() { stopRequested = true VpnLog.warn("VPN 权限被撤销") - closeActivePfd() + VpnBridge.stopSession() + allDone?.countDown() super.onRevoke() } override fun onDestroy() { - closeActivePfd() + if (captureThread?.isAlive != true) { + VpnBridge.stopSession() + VpnBridge.shutdownSession() + closeActivePfd() + } captureThread = null super.onDestroy() } diff --git a/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnBridge.kt b/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnBridge.kt index 9bb7292..989f478 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnBridge.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnBridge.kt @@ -2,30 +2,14 @@ package net.bluemangoo.indpjsk.vpn import android.util.Log -/** - * Rust core_vpn 的 JNI 桥。 - * - * 契约(由 Rust lib.rs 实现): - * runCapture(jint tunFd, jstring targetHosts, jstring path, jstring savePath, jstring proxy) -> String? - * - 阻塞直到抓包结束 - * - tunFd 的所有权【仍在 Kotlin 侧】,Rust 内部用 BorrowedAsyncDevice 借用,不得 close() - * - targetHosts 是逗号分隔的域名白名单(一个步骤可能对应多个上游域名) - * - proxy 可为 null / 空串(直连),否则必须是 http(s)://host:port 形式,Rust 侧会再校验一次 - * - 返回非 null = 抓到目标请求,值是命中的原始完整 URL(https://host/path?query),文件已写入 savePath - * - 返回 null = 失败 / 被 requestStop 打断 / 未匹配 - * - * requestStop() -> void - * - 立即返回,仅设置原子停止标志,使阻塞中的 runCapture 尽快返回 false - * - * nativeLog(String)(Kotlin -> Rust 回调的落地端) - * - Rust 通过 JNIEnv.call_static_method(".../VpnBridge", "nativeLog", ...) 调用 - * - 用于把 TLS 握手 / 匹配结果等关键日志写进 UI 日志框 - */ object VpnBridge { private const val TAG = "VpnBridge" private var loaded = false + @Volatile + var stepListener: ((String, String?) -> Unit)? = null + init { loaded = try { System.loadLibrary("core_vpn") @@ -39,19 +23,19 @@ object VpnBridge { fun isAvailable(): Boolean = loaded @JvmStatic - external fun runCapture( - tunFd: Int, - targetHosts: String, - targetPath: String, - savePath: String, - proxy: String? - ): String? + external fun boot(tunFd: Int, stepsJson: String, proxy: String?): Boolean @JvmStatic - external fun requestStop() + external fun stopSession() + + @JvmStatic + external fun shutdownSession() + + @JvmStatic + fun nativeStepResult(stepId: String, url: String?) { + stepListener?.invoke(stepId, url) + } - /** 供 Rust JNI 回调写入日志框;Rust 侧通过 call_static_method 调用本方法 */ - @Suppress("unused") @JvmStatic fun nativeLog(level: String, msg: String) { when (level) { @@ -61,4 +45,4 @@ object VpnBridge { else -> VpnLog.info(msg) } } -} \ No newline at end of file +} diff --git a/core_vpn/Cargo.lock b/core_vpn/Cargo.lock index 416c2e7..b752f76 100644 --- a/core_vpn/Cargo.lock +++ b/core_vpn/Cargo.lock @@ -278,6 +278,8 @@ dependencies = [ "rustls", "rustls-native-certs", "rustls-pemfile", + "serde", + "serde_json", "tokio", "tokio-rustls", "tokio-util", diff --git a/core_vpn/Cargo.toml b/core_vpn/Cargo.toml index 3be2cee..e8dd793 100644 --- a/core_vpn/Cargo.toml +++ b/core_vpn/Cargo.toml @@ -26,6 +26,8 @@ hyper-util = { version = "0.1", features = ["tokio", "server", "client"] } http-body-util = "0.1" http = "1.5.0" qstring = "0.7.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" url = "2" base64 = "0.23.1" diff --git a/core_vpn/src/lib.rs b/core_vpn/src/lib.rs index f5efbc7..6451323 100644 --- a/core_vpn/src/lib.rs +++ b/core_vpn/src/lib.rs @@ -1,18 +1,17 @@ mod cert; mod mitm; -mod tun; mod mut_qstring; +mod tun; -use crate::mitm::{ProxyConfig, run_capture_async}; +use crate::mitm::{ProxyConfig, StepSpec, Waiter, Waiters, run_session}; use jni::objects::{Global, JClass, JObject, JString}; -use jni::sys::{jint, jstring}; -use jni::errors::ThrowRuntimeExAndDefault; +use jni::sys::{jboolean, jint}; use jni::{Env, EnvUnowned, JValue, JavaVM, jni_sig, jni_str}; use regex::Regex; -use std::sync::{OnceLock, RwLock}; +use std::sync::{Arc, Mutex, OnceLock}; use tokio_util::sync::CancellationToken; -static CANCLE_TOKEN: RwLock> = RwLock::new(None); +static SESSION: Mutex> = Mutex::new(None); static JVM: OnceLock = OnceLock::new(); static VPN_BRIDGE_CLASS: OnceLock> = OnceLock::new(); @@ -80,101 +79,186 @@ macro_rules! error { ($($arg:tt)*) => {$crate::log_to_kotlin("error", &format!($($arg)*))}; } +struct Session { + cancel: CancellationToken, + waiters: Waiters, + done: std::sync::mpsc::Receiver<()>, + thread: Option>, +} + +pub fn notify_step_result(step_id: &str, url: Option<&str>) { + let Some(vm) = JVM.get() else { return }; + let Some(class_ref) = VPN_BRIDGE_CLASS.get() else { + return; + }; + + let _ = vm.attach_current_thread(|env: &mut Env| -> Result<(), jni::errors::Error> { + let jid = env.new_string(step_id)?; + let jurl = match url { + Some(u) => Some(env.new_string(u)?), + None => None, + }; + let jclass: &JClass = unsafe { &*(class_ref.as_obj() as *const JObject as *const JClass) }; + let url_value = match &jurl { + Some(s) => JValue::Object(s), + None => JValue::Object(&JObject::null()), + }; + env.call_static_method( + jclass, + jni_str!("nativeStepResult"), + jni_sig!("(Ljava/lang/String;Ljava/lang/String;)V"), + &[JValue::Object(&jid), url_value], + )?; + Ok(()) + }); +} + #[allow(non_snake_case)] #[unsafe(no_mangle)] -pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_runCapture( +pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_boot( mut env: EnvUnowned, _class: JClass, tun_fd: jint, - target_host: JString, - target_path: JString, - save_path: JString, + steps_json: JString, proxy: JString, -) -> jstring { +) -> jboolean { let _ = env.with_env(|env: &mut Env| -> Result<(), jni::errors::Error> { init_jvm(env); Ok(()) }); - - let host_arg: String = target_host.to_string(); - let path: String = target_path.to_string(); - let out: String = save_path.to_string(); - - let hosts: Vec = host_arg - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - if hosts.is_empty() { - error!("target hosts 为空"); - return std::ptr::null_mut(); + if SESSION.lock().unwrap().is_some() { + error!("会话已在运行,忽略重复 boot"); + return false; } - let re = match Regex::new(&path) { - Ok(re) => re, - Err(e) => { - error!("Invalid regex pattern: {path} ({e})"); - return std::ptr::null_mut(); - } - }; - + let steps_raw: String = steps_json.to_string(); let proxy_raw: String = if proxy.is_null() { String::new() } else { proxy.to_string() }; + + let steps: Vec = match serde_json::from_str(&steps_raw) { + Ok(v) => v, + Err(e) => { + error!("steps JSON 解析失败: {e}"); + return false; + } + }; + if steps.is_empty() { + error!("没有需要抓的步骤"); + return false; + } + let proxy_cfg = match ProxyConfig::parse(&proxy_raw) { Ok(cfg) => cfg, Err(e) => { error!("代理配置无效: {e}"); - return std::ptr::null_mut(); + return false; } }; if let Some(cfg) = &proxy_cfg { - info!("upstream proxy: {}://{}:{}", if cfg.tls { "https" } else { "http" }, cfg.host, cfg.port); + info!( + "upstream proxy: {}://{}:{}", + if cfg.tls { "https" } else { "http" }, + cfg.host, + cfg.port + ); } - let rt = tokio::runtime::Runtime::new().unwrap(); - let cancel_token = CancellationToken::new(); - *CANCLE_TOKEN.write().unwrap() = Some(cancel_token.clone()); - - let cancel_token_clone = cancel_token.clone(); - let result = rt.block_on(async move { - run_capture_async(tun_fd, hosts, re, out, proxy_cfg, cancel_token_clone).await - }); - cancel_token.cancel(); - - rt.shutdown_timeout(std::time::Duration::from_secs(2)); - - let url = result.unwrap_or_else(|e| { - error!("capture failed: {e:?}"); - None - }); - - let obj: JObject = env - .with_env(|env: &mut Env| -> Result { - match &url { - Some(url) => { - let s = env.new_string(url)?; - Ok(unsafe { JObject::from_raw(env, s.as_raw()) }) - } - None => Ok(JObject::null()), + let mut hosts: Vec = Vec::new(); + let mut waiter_list: Vec = Vec::new(); + for step in steps { + let re = match Regex::new(&step.path) { + Ok(re) => re, + Err(e) => { + error!("步骤 {} 的正则不合法: {e}", step.id); + return false; } - }) - .resolve::(); + }; + for h in step.hosts { + if !h.is_empty() && !hosts.iter().any(|x| x.eq_ignore_ascii_case(&h)) { + hosts.push(h); + } + } + waiter_list.push(Waiter { + id: step.id, + path: re, + save_path: step.save_path, + }); + } + if hosts.is_empty() { + error!("所有步骤的 host 都是空的"); + return false; + } - obj.into_raw() + let cancel = CancellationToken::new(); + let waiters: Waiters = Arc::new(Mutex::new(waiter_list)); + let (done_tx, done_rx) = std::sync::mpsc::channel::<()>(); + + let thread_waiters = waiters.clone(); + let thread_cancel = cancel.clone(); + let thread = match std::thread::Builder::new() + .name("vpn-session".into()) + .spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + if let Err(e) = rt.block_on(run_session( + tun_fd, + hosts, + proxy_cfg, + thread_waiters, + thread_cancel, + )) { + error!("session error: {e:?}"); + } + rt.shutdown_timeout(std::time::Duration::from_secs(2)); + let _ = done_tx.send(()); + }) { + Ok(t) => t, + Err(e) => { + error!("启动 session 线程失败: {e}"); + return false; + } + }; + + *SESSION.lock().unwrap() = Some(Session { + cancel, + waiters, + done: done_rx, + thread: Some(thread), + }); + + true } #[allow(non_snake_case)] #[unsafe(no_mangle)] -pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_requestStop( +pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_stopSession( _env: EnvUnowned, _class: JClass, ) { - info!("Stopping capture..."); - if let Some(token) = CANCLE_TOKEN.write().unwrap().take() { - token.cancel() + info!("session stop requested"); + if let Some(session) = SESSION.lock().unwrap().as_ref() { + session.waiters.lock().unwrap().clear(); + session.cancel.cancel(); } } + +#[allow(non_snake_case)] +#[unsafe(no_mangle)] +pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_shutdownSession( + _env: EnvUnowned, + _class: JClass, +) { + let session = SESSION.lock().unwrap().take(); + let Some(mut session) = session else { return }; + + session.waiters.lock().unwrap().clear(); + session.cancel.cancel(); + let _ = session.done.recv(); + if let Some(t) = session.thread.take() { + let _ = t.join(); + } + info!("session shutdown complete"); +} diff --git a/core_vpn/src/mitm.rs b/core_vpn/src/mitm.rs index f6dd3ed..ac9d4a9 100644 --- a/core_vpn/src/mitm.rs +++ b/core_vpn/src/mitm.rs @@ -12,12 +12,11 @@ use rustls::RootCertStore; use std::io; use std::net::SocketAddr; use std::os::fd::RawFd; -use std::sync::{Arc, OnceLock}; +use std::sync::{Arc, Mutex as StdMutex, OnceLock}; use std::time::Duration; use http::Uri; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::net::UdpSocket; -use tokio::sync::Mutex; use tokio::task::JoinSet; use tokio_rustls::StartHandshake; use tokio_util::sync::CancellationToken; @@ -115,11 +114,9 @@ async fn handle_tls_connection( dst: SocketAddr, upstream: Upstream, target_hosts: Vec, - target_path: Regex, - save_path: String, + waiters: Waiters, server_config: Arc, cancel: CancellationToken, - result_tx: Arc>>>, ) -> anyhow::Result<()> where S: AsyncRead + AsyncWrite + Unpin + Send + 'static, @@ -136,7 +133,6 @@ where let Some((sni, accepted)) = sniffed else { io.rewind(); io.stop_recording(); - // 非 TLS:拿不到 SNI,走代理时只能按 IP CONNECT return passthrough_cancel_wrapped(io, dst, None, upstream, &cancel).await; }; @@ -154,7 +150,6 @@ where } return r; } - info!("Encountered target host."); io.stop_recording(); let client_tls = StartHandshake::from_parts(accepted, io) @@ -162,16 +157,13 @@ where .await?; let svc_host = sni.clone().unwrap_or_default(); - let svc_upstream = upstream; let svc = hyper::service::service_fn(move |req: hyper::Request| { let host = svc_host.clone(); - let path = target_path.clone(); - let save = save_path.clone(); - let result_tx = result_tx.clone(); - let upstream = svc_upstream.clone(); + let waiters = waiters.clone(); + let upstream = upstream.clone(); async move { Ok::<_, std::convert::Infallible>( - forward_request(req, dst, host, path, save, result_tx, upstream).await, + forward_request(req, dst, host, waiters, upstream).await, ) } }); @@ -191,6 +183,7 @@ where break; } _ = cancel.cancelled(), if !graceful => { + info!("teardown: graceful shutdown of downstream connection"); conn.as_mut().graceful_shutdown(); graceful = true; } @@ -218,38 +211,42 @@ where #[allow(clippy::too_many_arguments)] async fn forward_request( - req: hyper::Request, + mut req: hyper::Request, dst: SocketAddr, url_host: String, - target_path: Regex, - save_path: String, - result_tx: Arc>>>, + waiters: Waiters, upstream: Upstream, ) -> hyper::Response> { - match forward_request_inner(req, dst, &url_host, &target_path, &upstream).await { - Ok((response, matched_body)) => { - if let Some((url, body)) = matched_body { - info!("Target matched! Downloading {} bytes", body.len()); - let path = std::path::Path::new(&save_path); - if !path.is_absolute() { - error!("Failed to resolve path: {save_path}"); - } - if let Some(parent) = path.parent() { - let _ = tokio::fs::create_dir_all(parent).await; - } - match tokio::fs::write(path, &body).await { - Ok(()) => { - if let Some(tx) = result_tx.lock().await.take() { - let _ = tx.send(url); - } - } - Err(e) => error!("write {save_path} failed: {e:?}"), - } + if let Some(rewritten) = force_full_mysekai(req.uri()) { + warn!("rewrite mysekai flag: {} -> {}", req.uri(), rewritten); + *req.uri_mut() = rewritten; + } + + let uri = req.uri().clone(); + let url = build_url(&url_host, &uri); + let path_and_query = uri + .path_and_query() + .map(|p| p.as_str().to_string()) + .unwrap_or_else(|| "/".to_string()); + + let waiter = take_matching_waiter(&waiters, &path_and_query); + if let Some(w) = &waiter { + info!("step '{}' matched", w.id); + } + let want_body = waiter.is_some(); + + match forward_request_inner(req, dst, &url_host, upstream, want_body).await { + Ok((response, saved_body)) => { + if let (Some(w), Some(body)) = (waiter, saved_body) { + save_and_notify(w, url, body); } response } Err(e) => { error!("forward to {dst} failed: {e:?}"); + if let Some(w) = waiter { + crate::notify_step_result(&w.id, None); + } hyper::Response::builder() .status(hyper::StatusCode::BAD_GATEWAY) .header("content-type", "text/plain; charset=utf-8") @@ -259,28 +256,39 @@ async fn forward_request( } } +fn save_and_notify(waiter: Waiter, url: String, body: Bytes) { + tokio::spawn(async move { + let path = std::path::Path::new(&waiter.save_path); + if !path.is_absolute() { + error!("save_path 不是绝对路径({})", waiter.save_path); + } + if let Some(parent) = path.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + match tokio::fs::write(path, &body).await { + Ok(()) => { + info!("step '{}' saved ({} bytes)", waiter.id, body.len()); + crate::notify_step_result(&waiter.id, Some(&url)); + } + Err(e) => { + error!("write {} failed: {e:?}", waiter.save_path); + crate::notify_step_result(&waiter.id, None); + } + } + }); +} + async fn forward_request_inner( req: hyper::Request, dst: SocketAddr, url_host: &str, - target_path: &Regex, - upstream: &Upstream, -) -> anyhow::Result<(hyper::Response>, Option<(String, Bytes)>)> { + upstream: Upstream, + want_body: bool, +) -> anyhow::Result<(hyper::Response>, Option)> { let (mut parts, body) = req.into_parts(); - - if let Some(rewritten) = force_full_mysekai(&parts.uri) { - warn!("rewrite mysekai flag: {} -> {}", parts.uri, rewritten); - parts.uri = rewritten; - } - - let parts_uri = parts.uri.clone(); - let path_and_query = parts_uri - .path_and_query() - .map(|p| p.as_str().to_string()) - .unwrap_or_else(|| "/".to_string()); let body_bytes = body.collect().await?.to_bytes(); - // 拒绝长链接,反正抓到就跑。 + // 拒绝长链接 for h in [ "connection", "keep-alive", @@ -297,7 +305,6 @@ async fn forward_request_inner( let mut client_config = rustls::ClientConfig::builder() .with_root_certificates(root_store()) .with_no_client_auth(); - // 问就是有服务器不接受 H2 client_config.alpn_protocols = vec![b"http/1.1".to_vec()]; let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config)); let server_name = rustls::pki_types::ServerName::try_from(url_host.to_string())?; @@ -329,17 +336,14 @@ async fn forward_request_inner( res_parts.headers.remove(h); } - let is_target = target_path.is_match(&path_and_query); - let url = build_url(url_host, &parts_uri); - - let save = if is_target { - Some((url, res_bytes.clone())) + let saved = if want_body { + Some(res_bytes.clone()) } else { None }; Ok(( hyper::Response::from_parts(res_parts, Full::new(res_bytes)), - save, + saved, )) } @@ -362,6 +366,30 @@ fn force_full_mysekai(uri: &Uri) -> Option { } +#[derive(serde::Deserialize, Debug, Clone)] +pub struct StepSpec { + pub id: String, + #[serde(default)] + pub hosts: Vec, + pub path: String, + #[serde(rename = "savePath")] + pub save_path: String, +} + +pub struct Waiter { + pub id: String, + pub path: Regex, + pub save_path: String, +} + +pub type Waiters = Arc>>; + +fn take_matching_waiter(waiters: &StdMutex>, path_and_query: &str) -> Option { + let mut list = waiters.lock().unwrap(); + let idx = list.iter().position(|w| w.path.is_match(path_and_query))?; + Some(list.remove(idx)) +} + #[derive(Clone, Debug)] pub struct ProxyConfig { pub tls: bool, @@ -500,14 +528,13 @@ async fn connect_via_proxy( Ok(io) } -pub async fn run_capture_async( +pub async fn run_session( tun_fd: i32, target_hosts: Vec, - target_path: Regex, - save_path: String, proxy: Option, - cancel_token: CancellationToken, -) -> anyhow::Result> { + waiters: Waiters, + cancel: CancellationToken, +) -> anyhow::Result<()> { let device = unsafe { BorrowedAsyncDevice::borrow_raw(tun_fd as RawFd)? }; let mut ipstack_config = ipstack::IpStackConfig::default(); @@ -520,19 +547,11 @@ pub async fn run_capture_async( let tls_acceptor = build_tls_acceptor(); let upstream = Upstream::new(proxy); - let (result_tx, mut result_rx) = tokio::sync::oneshot::channel::(); - let result_tx = Arc::new(Mutex::new(Some(result_tx))); - - let result: anyhow::Result> = loop { - let token = cancel_token.clone(); + loop { + let token = cancel.clone(); tokio::select! { - _ = cancel_token.cancelled() => { - let final_result = result_rx.try_recv().ok(); - break Ok(final_result); - } - Ok(url) = &mut result_rx => { - cancel_token.cancel(); - break Ok(Some(url)); + _ = cancel.cancelled() => { + break; } _ = bridge_tasks.join_next(), if !bridge_tasks.is_empty() => {} @@ -541,7 +560,7 @@ pub async fn run_capture_async( Ok(s) => s, Err(e) => { error!("ip_stack accept error (device might be dropped): {e:?}"); - break Err(anyhow::Error::from(e)); + break; } }; @@ -551,7 +570,7 @@ pub async fn run_capture_async( if dst.port() == 443 { // drop quic roughly drop(udp); - } else{ + } else { bridge_tasks.spawn(async move { if let Err(e) = udp_passthrough(udp, dst, token).await { error!("udp passthrough error: {e:?}"); @@ -564,7 +583,7 @@ pub async fn run_capture_async( if dst.port() == 443 { let (app_io, mut tun_io) = tokio::io::duplex(256 * 1024); - let pump_token = cancel_token.clone(); + let pump_token = cancel.clone(); bridge_tasks.spawn(async move { tokio::select! { _ = pump_token.cancelled() => { @@ -585,11 +604,9 @@ pub async fn run_capture_async( }); let hosts = target_hosts.clone(); - let path = target_path.clone(); - let save = save_path.clone(); let server_config = tls_acceptor.clone(); - let token = cancel_token.clone(); - let result_tx = result_tx.clone(); + let token = cancel.clone(); + let waiters = waiters.clone(); let upstream = upstream.clone(); tokio::spawn(async move { @@ -598,18 +615,16 @@ pub async fn run_capture_async( dst, upstream, hosts, - path, - save, + waiters, server_config, token, - result_tx ).await { - Ok(()) => { } + Ok(()) => {} Err(e) => error!("handle tls conn error: {e:?}"), } }); - }else{ - let pump_token = cancel_token.clone(); + } else { + let pump_token = cancel.clone(); let upstream = upstream.clone(); bridge_tasks.spawn(async move { let _ = @@ -622,7 +637,7 @@ pub async fn run_capture_async( } } } - }; + } let _ = tokio::time::timeout(TEARDOWN_GRACE, async { while bridge_tasks.join_next().await.is_some() {} @@ -631,7 +646,8 @@ pub async fn run_capture_async( bridge_tasks.abort_all(); while bridge_tasks.join_next().await.is_some() {} - result + info!("session stopped"); + Ok(()) } async fn tcp_passthrough(