复用连接

This commit is contained in:
2026-10-28 13:51:20 +08:00
parent 0851713876
commit 5739f7f8e7
15 changed files with 581 additions and 376 deletions
+2 -2
View File
@@ -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 的提示,请允许 - 点击开始,如果跳出建立 VPN 的提示,请允许
- 启动游戏,进入游戏触发需要抓的包,应用会自动抓取数据并自动结束,如果配置了上传会自动上传 - 启动游戏,进入游戏触发需要抓的包,应用会自动抓取数据并自动结束;每一步抓到后就会立刻执行该步的后处理(勾选了上传就会马上上传,不用等所有步骤)
## 触发位置 ## 触发位置
+2 -2
View File
@@ -18,8 +18,8 @@ android {
applicationId = "net.bluemangoo.indpjsk" applicationId = "net.bluemangoo.indpjsk"
minSdk = appMinSdk minSdk = appMinSdk
targetSdk = 37 targetSdk = 37
versionCode = 3 versionCode = 4
versionName = "1.2" versionName = "1.3"
} }
@@ -25,10 +25,11 @@ import net.bluemangoo.indpjsk.ui.CaptureScreen
import net.bluemangoo.indpjsk.ui.CaptureViewModel import net.bluemangoo.indpjsk.ui.CaptureViewModel
import net.bluemangoo.indpjsk.ui.CapturedFilesScreen import net.bluemangoo.indpjsk.ui.CapturedFilesScreen
import net.bluemangoo.indpjsk.ui.ProxySettingsScreen import net.bluemangoo.indpjsk.ui.ProxySettingsScreen
import net.bluemangoo.indpjsk.ui.UploadSettingsScreen
import net.bluemangoo.indpjsk.ui.theme.IndPjskTheme import net.bluemangoo.indpjsk.ui.theme.IndPjskTheme
import java.io.File 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() { class MainActivity : ComponentActivity() {
@@ -73,10 +74,15 @@ class MainActivity : ComponentActivity() {
onStartCapture = { requestVpnAndStart() }, onStartCapture = { requestVpnAndStart() },
onStopCapture = { viewModel.stopCapture(this) }, onStopCapture = { viewModel.stopCapture(this) },
onOpenProxySettings = { screen = AppScreen.PROXY }, onOpenProxySettings = { screen = AppScreen.PROXY },
onOpenUploadSettings = { screen = AppScreen.UPLOAD },
onOpenFiles = { screen = AppScreen.FILES }, onOpenFiles = { screen = AppScreen.FILES },
onOpenAbout = { screen = AppScreen.ABOUT }, onOpenAbout = { screen = AppScreen.ABOUT },
) )
AppScreen.UPLOAD -> UploadSettingsScreen(
onBack = { screen = AppScreen.MAIN },
)
AppScreen.PROXY -> ProxySettingsScreen( AppScreen.PROXY -> ProxySettingsScreen(
bypassPackages = bypassPackages, bypassPackages = bypassPackages,
onBypassChange = { newSet -> onBypassChange = { newSet ->
@@ -3,7 +3,7 @@ package net.bluemangoo.indpjsk.data
import net.bluemangoo.indpjsk.upload.Uploader import net.bluemangoo.indpjsk.upload.Uploader
object PredefinedProfiles { 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 = private const val MYSEKAI_PATH =
"/api/user/(\\d+)/mysekai\\?isForceAllReloadOnlyMysekai=True" "/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 JP_HOSTS = listOf("production-game-api.sekai.colorfulpalette.org")
private val EN_HOSTS = listOf("n-production-game-api.sekai-en.com") private val EN_HOSTS = listOf("n-production-game-api.sekai-en.com")
private val TW_HOSTS = listOf( private val TW_HOSTS = listOf(
@@ -3,19 +3,19 @@ package net.bluemangoo.indpjsk.data
import android.content.Context import android.content.Context
import androidx.core.content.edit import androidx.core.content.edit
/** 全局配置持久化:上传码、上游代理、忽略的应用、选中的配置 */
object SettingsStore { object SettingsStore {
private const val PREFS = "settings" private const val PREFS = "settings"
private const val KEY_UPLOAD_CODE = "upload_code" 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_PROXY = "upstream_proxy"
private const val KEY_USE_PROXY = "use_proxy"
private const val KEY_BYPASS = "bypass_packages" private const val KEY_BYPASS = "bypass_packages"
private const val KEY_PROFILE = "selected_profile" private const val KEY_PROFILE = "selected_profile"
private fun prefs(context: Context) = private fun prefs(context: Context) =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
// ---- 上传码 ----
fun loadUploadCode(context: Context): String = fun loadUploadCode(context: Context): String =
prefs(context).getString(KEY_UPLOAD_CODE, "").orEmpty() prefs(context).getString(KEY_UPLOAD_CODE, "").orEmpty()
@@ -23,7 +23,15 @@ object SettingsStore {
prefs(context).edit { putString(KEY_UPLOAD_CODE, code) } 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 = fun loadProxy(context: Context): String =
prefs(context).getString(KEY_PROXY, "").orEmpty() prefs(context).getString(KEY_PROXY, "").orEmpty()
@@ -31,7 +39,15 @@ object SettingsStore {
prefs(context).edit { putString(KEY_PROXY, proxy.trim()) } 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<String> = fun loadBypassPackages(context: Context): Set<String> =
prefs(context).getStringSet(KEY_BYPASS, emptySet())?.toSet() ?: emptySet() prefs(context).getStringSet(KEY_BYPASS, emptySet())?.toSet() ?: emptySet()
@@ -39,7 +55,7 @@ object SettingsStore {
prefs(context).edit { putStringSet(KEY_BYPASS, packages) } prefs(context).edit { putStringSet(KEY_BYPASS, packages) }
} }
// ---- 当前选中的配置 ----
fun loadSelectedProfileId(context: Context): String? = fun loadSelectedProfileId(context: Context): String? =
prefs(context).getString(KEY_PROFILE, null) prefs(context).getString(KEY_PROFILE, null)
@@ -15,14 +15,12 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import net.bluemangoo.indpjsk.R import net.bluemangoo.indpjsk.R
import net.bluemangoo.indpjsk.data.CaptureProfile import net.bluemangoo.indpjsk.data.CaptureProfile
import net.bluemangoo.indpjsk.data.CaptureProfileStep import net.bluemangoo.indpjsk.data.CaptureProfileStep
import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.data.StepSelectionStore import net.bluemangoo.indpjsk.data.StepSelectionStore
import net.bluemangoo.indpjsk.state.CaptureStatus import net.bluemangoo.indpjsk.state.CaptureStatus
import net.bluemangoo.indpjsk.vpn.LogEntry import net.bluemangoo.indpjsk.vpn.LogEntry
@@ -36,6 +34,7 @@ fun CaptureScreen(
onStartCapture: () -> Unit, onStartCapture: () -> Unit,
onStopCapture: () -> Unit, onStopCapture: () -> Unit,
onOpenProxySettings: () -> Unit, onOpenProxySettings: () -> Unit,
onOpenUploadSettings: () -> Unit,
onOpenFiles: () -> Unit, onOpenFiles: () -> Unit,
onOpenAbout: () -> Unit, onOpenAbout: () -> Unit,
) { ) {
@@ -46,7 +45,6 @@ fun CaptureScreen(
var enabledMap by remember { mutableStateOf(StepSelectionStore.loadAll(context) as Map<String, Boolean>) } var enabledMap by remember { mutableStateOf(StepSelectionStore.loadAll(context) as Map<String, Boolean>) }
var menuExpanded by remember { mutableStateOf(false) } var menuExpanded by remember { mutableStateOf(false) }
var profileExpanded by remember { mutableStateOf(false) } var profileExpanded by remember { mutableStateOf(false) }
var showUploadCodeDialog by remember { mutableStateOf(false) }
val isRunning = state.status == CaptureStatus.RUNNING val isRunning = state.status == CaptureStatus.RUNNING
val isCaptured = state.status == CaptureStatus.CAPTURED val isCaptured = state.status == CaptureStatus.CAPTURED
@@ -69,10 +67,10 @@ fun CaptureScreen(
onDismissRequest = { menuExpanded = false } onDismissRequest = { menuExpanded = false }
) { ) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("上传码") }, text = { Text("上传设置") },
onClick = { onClick = {
menuExpanded = false menuExpanded = false
showUploadCodeDialog = true onOpenUploadSettings()
} }
) )
DropdownMenuItem( 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 @Composable
@@ -29,6 +29,7 @@ fun ProxySettingsScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
var proxy by remember { mutableStateOf(SettingsStore.loadProxy(context)) } var proxy by remember { mutableStateOf(SettingsStore.loadProxy(context)) }
var useProxy by remember { mutableStateOf(SettingsStore.loadUseProxy(context)) }
var pendingRemove by remember { mutableStateOf<AppEntry?>(null) } var pendingRemove by remember { mutableStateOf<AppEntry?>(null) }
val entries = remember(bypassPackages) { val entries = remember(bypassPackages) {
@@ -37,6 +38,7 @@ fun ProxySettingsScreen(
val looksValid = proxy.isBlank() || val looksValid = proxy.isBlank() ||
((proxy.startsWith("http://") || proxy.startsWith("https://")) && proxy.contains(":")) ((proxy.startsWith("http://") || proxy.startsWith("https://")) && proxy.contains(":"))
val canUseProxy = proxy.isNotBlank() && looksValid
Scaffold( Scaffold(
topBar = { topBar = {
@@ -66,11 +68,35 @@ fun ProxySettingsScreen(
) { ) {
Text("上游代理", style = MaterialTheme.typography.labelLarge) 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( OutlinedTextField(
value = proxy, value = proxy,
onValueChange = { onValueChange = {
proxy = it proxy = it
SettingsStore.saveProxy(context, it) SettingsStore.saveProxy(context, it)
// 地址被清空时不允许再勾着;顺手把偏好也清掉,避免重新填写时自动勾上
if (it.isBlank() && useProxy) {
useProxy = false
SettingsStore.saveUseProxy(context, false)
}
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
@@ -79,10 +105,10 @@ fun ProxySettingsScreen(
isError = !looksValid, isError = !looksValid,
supportingText = { supportingText = {
Text( Text(
text = if (looksValid) { text = when {
"留空以忽略;只支持 http / https 代理" proxy.isBlank() -> "填写地址后才能勾选启用;只支持 http / https 代理"
} else { !looksValid -> "格式示例:http://127.0.0.1:7890"
"格式示例:http://127.0.0.1:7890" else -> "只支持 http / https 代理"
} }
) )
} }
@@ -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
)
}
}
}
}
@@ -59,13 +59,18 @@ object Uploader {
} }
suspend fun tryUpload(result: CaptureResult) { suspend fun tryUpload(result: CaptureResult) {
val uploadCode = SettingsStore.loadUploadCode(App.appContext).trim() val context = App.appContext
if (uploadCode.isNotEmpty()) { // 复选框没勾就不传(没填上传码时复选框本身不可用)
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} 开始上传") VpnLog.info("${result.profile.name}${result.step.name} 开始上传")
upload(uploadCode, result.url, result.file) upload(uploadCode, result.url, result.file)
VpnLog.success("${result.profile.name}${result.step.name} 上传完成") VpnLog.success("${result.profile.name}${result.step.name} 上传完成")
} }
}
private fun postChunk( private fun postChunk(
uploadCode: String, uploadCode: String,
@@ -7,7 +7,11 @@ import android.os.ParcelFileDescriptor
import kotlinx.coroutines.* import kotlinx.coroutines.*
import net.bluemangoo.indpjsk.data.* import net.bluemangoo.indpjsk.data.*
import net.bluemangoo.indpjsk.state.CaptureStateHolder import net.bluemangoo.indpjsk.state.CaptureStateHolder
import org.json.JSONArray
import org.json.JSONObject
import java.io.File import java.io.File
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
@SuppressLint("VpnServicePolicy") @SuppressLint("VpnServicePolicy")
class CaptureVpnService : VpnService() { class CaptureVpnService : VpnService() {
@@ -23,9 +27,7 @@ class CaptureVpnService : VpnService() {
private val postScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val postScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun captureFile( fun captureFile(
filesDir: File, filesDir: File, profile: CaptureProfile, step: CaptureProfileStep
profile: CaptureProfile,
step: CaptureProfileStep
): File = File(File(File(filesDir, "captures"), profile.id), step.fileName) ): File = File(File(File(filesDir, "captures"), profile.id), step.fileName)
} }
@@ -38,6 +40,16 @@ class CaptureVpnService : VpnService() {
@Volatile @Volatile
private var stopRequested = false private var stopRequested = false
private val stepResults = ConcurrentHashMap<String, String>()
private val finishedSteps = ConcurrentHashMap.newKeySet<String>()
@Volatile
private var allDone: CountDownLatch? = null
@Volatile
private var lastCaptured: File? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) { when (intent?.action) {
@@ -49,12 +61,14 @@ class CaptureVpnService : VpnService() {
ACTION_STOP -> { ACTION_STOP -> {
stopRequested = true stopRequested = true
VpnLog.warn("手动停止…") VpnBridge.stopSession()
VpnBridge.requestStop() allDone?.countDown()
closeActivePfd()
CaptureStateHolder.setIdle() CaptureStateHolder.setIdle()
if (captureThread?.isAlive != true) {
closeActivePfd()
stopSelf() stopSelf()
} }
}
else -> return START_NOT_STICKY else -> return START_NOT_STICKY
} }
@@ -66,7 +80,6 @@ class CaptureVpnService : VpnService() {
activePfd = null activePfd = null
try { try {
pfd?.close() pfd?.close()
VpnBridge.requestStop()
} catch (t: Throwable) { } catch (t: Throwable) {
VpnLog.warn("关闭代理失败: ${t.message}") VpnLog.warn("关闭代理失败: ${t.message}")
} }
@@ -88,22 +101,47 @@ class CaptureVpnService : VpnService() {
return 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) val bypassPackages = SettingsStore.loadBypassPackages(this)
if (proxy.isNotEmpty()) VpnLog.info("上游代理: $proxy") if (proxy != null) {
VpnLog.info("上游代理: $proxy")
} else {
VpnLog.info("未启用上游代理")
}
if (bypassPackages.isNotEmpty()) { if (bypassPackages.isNotEmpty()) {
VpnLog.info("忽略的应用: ${bypassPackages.joinToString(", ")}") VpnLog.info("忽略的应用: ${bypassPackages.joinToString(", ")}")
} }
stopRequested = false stopRequested = false
stepResults.clear()
finishedSteps.clear()
lastCaptured = null
val latch = CountDownLatch(1)
allDone = latch
VpnLog.info("==== 开始抓包 ====") VpnLog.info("==== 开始抓包 ====")
VpnLog.info("配置: ${profile.name}") 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({ captureThread = Thread({
var pfd: ParcelFileDescriptor? var pfd: ParcelFileDescriptor?
var lastFile: File? = null
var completed = 0
var errorMsg: String? = null var errorMsg: String? = null
try { try {
@@ -111,159 +149,131 @@ class CaptureVpnService : VpnService() {
throw IllegalStateException("Rust 动态库 core_vpn 未加载") throw IllegalStateException("Rust 动态库 core_vpn 未加载")
} }
val builder = Builder() val builder = Builder().setSession("Capture-${profile.id}").addAddress(VPN_ADDRESS, VPN_PREFIX)
.setSession("Capture-${profile.id}") .addDisallowedApplication(packageName).addRoute("1.0.0.0", 8)
.addAddress(VPN_ADDRESS, VPN_PREFIX)
.addDisallowedApplication(packageName)
.addRoute("1.0.0.0", 8)
.addRoute("2.0.0.0", 7) .addRoute("2.0.0.0", 7).addRoute("4.0.0.0", 6).addRoute("8.0.0.0", 7)
.addRoute("4.0.0.0", 6)
.addRoute("8.0.0.0", 7)
.addRoute("11.0.0.0", 8) .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("12.0.0.0", 6) .addRoute("64.0.0.0", 5).addRoute("96.0.0.0", 10)
.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("100.128.0.0", 9).addRoute("101.0.0.0", 8).addRoute("102.0.0.0", 7)
.addRoute("101.0.0.0", 8) .addRoute("104.0.0.0", 5).addRoute("112.0.0.0", 4)
.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("128.0.0.0", 3).addRoute("160.0.0.0", 5).addRoute("168.0.0.0", 6).addRoute("169.0.0.0", 9)
.addRoute("160.0.0.0", 5) .addRoute("169.128.0.0", 10).addRoute("169.192.0.0", 11).addRoute("169.224.0.0", 12)
.addRoute("168.0.0.0", 6) .addRoute("169.240.0.0", 13).addRoute("169.248.0.0", 14).addRoute("169.252.0.0", 15)
.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("169.255.0.0", 16).addRoute("170.0.0.0", 7).addRoute("172.0.0.0", 12)
.addRoute("170.0.0.0", 7)
.addRoute("172.0.0.0", 12)
.addRoute("172.32.0.0", 11) .addRoute("172.32.0.0", 11).addRoute("172.64.0.0", 10).addRoute("172.128.0.0", 9)
.addRoute("172.64.0.0", 10) .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("172.128.0.0", 9) .addRoute("192.128.0.0", 11).addRoute("192.160.0.0", 13).addRoute("192.169.0.0", 16)
.addRoute("173.0.0.0", 8) .addRoute("192.170.0.0", 15).addRoute("192.172.0.0", 14).addRoute("192.176.0.0", 12)
.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("192.192.0.0", 10)
.addRoute("193.0.0.0", 8) .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("194.0.0.0", 7) .addRoute("208.0.0.0", 4).addRoute("::", 0).addDnsServer("8.8.8.8").addDnsServer("114.114.114.114")
.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) { for (pkg in bypassPackages) {
if (pkg == packageName) continue if (pkg == packageName) continue
runCatching { builder.addDisallowedApplication(pkg) } runCatching { builder.addDisallowedApplication(pkg) }.onFailure { VpnLog.warn("忽略应用 $pkg 失败: ${it.message}") }
.onFailure { VpnLog.warn("忽略应用 $pkg 失败: ${it.message}") }
} }
pfd = builder.establish() pfd = builder.establish() ?: throw IllegalStateException("VpnService.establish() 返回 null")
?: throw IllegalStateException("VpnService.establish() 返回 null")
activePfd = pfd activePfd = pfd
val fd = pfd.fd val fd = pfd.fd
VpnLog.info("网络代理已连接")
for ((index, step) in enabledSteps.withIndex()) { 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()
if (!VpnBridge.boot(fd, stepsJson, proxy)) {
throw IllegalStateException("tun core 启动失败")
}
VpnLog.info("VPN 已建立")
latch.await()
if (stopRequested) { if (stopRequested) {
VpnLog.warn("用户停止,终止后续步骤") VpnLog.warn("用户停止,已抓到 ${stepResults.size}/${enabledSteps.size} 步")
break } else if (stepResults.size < enabledSteps.size) {
} errorMsg = "只抓到 ${stepResults.size}/${enabledSteps.size} 步"
val label = "[${index + 1}/${enabledSteps.size}] ${step.name}"
val target = captureFile(filesDir, profile, step)
target.parentFile?.mkdirs()
if (target.exists()) target.delete()
val capturedUrl = VpnBridge.runCapture(
fd,
step.hosts.joinToString(","),
step.path,
target.absolutePath,
proxy
)
if (capturedUrl == null) {
if (stopRequested) {
VpnLog.warn("$label 被用户停止")
break
}
errorMsg = "$label 未抓到目标请求"
VpnLog.error(errorMsg) 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}")
}
}
} }
} catch (t: Throwable) { } catch (t: Throwable) {
errorMsg = t.message ?: "抓包异常" errorMsg = t.message ?: "抓包异常"
VpnLog.error("抓包异常: ${t.message}") VpnLog.error("抓包异常: ${t.message}")
} finally { } finally {
VpnBridge.stepListener = null
VpnBridge.stopSession()
VpnBridge.shutdownSession()
closeActivePfd() closeActivePfd()
VpnLog.info("==== 抓包结束($completed/${enabledSteps.size} 步完成)====") VpnLog.info("==== 抓包结束(${stepResults.size}/${enabledSteps.size} 步完成)====")
if (errorMsg != null) { if (errorMsg != null) {
CaptureStateHolder.setError(errorMsg) CaptureStateHolder.setError(errorMsg)
} else if (completed == enabledSteps.size && lastFile != null) { } else if (stepResults.size == enabledSteps.size && lastCaptured != null) {
CaptureStateHolder.setCaptured(lastFile) CaptureStateHolder.setCaptured(lastCaptured!!)
} else { } else {
CaptureStateHolder.setIdle() CaptureStateHolder.setIdle()
} }
allDone = null
stopSelf() stopSelf()
} }
}, "capture-blocking-worker").apply { start() } }, "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() { override fun onRevoke() {
stopRequested = true stopRequested = true
VpnLog.warn("VPN 权限被撤销") VpnLog.warn("VPN 权限被撤销")
closeActivePfd() VpnBridge.stopSession()
allDone?.countDown()
super.onRevoke() super.onRevoke()
} }
override fun onDestroy() { override fun onDestroy() {
if (captureThread?.isAlive != true) {
VpnBridge.stopSession()
VpnBridge.shutdownSession()
closeActivePfd() closeActivePfd()
}
captureThread = null captureThread = null
super.onDestroy() super.onDestroy()
} }
@@ -2,30 +2,14 @@ package net.bluemangoo.indpjsk.vpn
import android.util.Log 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 { object VpnBridge {
private const val TAG = "VpnBridge" private const val TAG = "VpnBridge"
private var loaded = false private var loaded = false
@Volatile
var stepListener: ((String, String?) -> Unit)? = null
init { init {
loaded = try { loaded = try {
System.loadLibrary("core_vpn") System.loadLibrary("core_vpn")
@@ -39,19 +23,19 @@ object VpnBridge {
fun isAvailable(): Boolean = loaded fun isAvailable(): Boolean = loaded
@JvmStatic @JvmStatic
external fun runCapture( external fun boot(tunFd: Int, stepsJson: String, proxy: String?): Boolean
tunFd: Int,
targetHosts: String,
targetPath: String,
savePath: String,
proxy: String?
): String?
@JvmStatic @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 @JvmStatic
fun nativeLog(level: String, msg: String) { fun nativeLog(level: String, msg: String) {
when (level) { when (level) {
+2
View File
@@ -278,6 +278,8 @@ dependencies = [
"rustls", "rustls",
"rustls-native-certs", "rustls-native-certs",
"rustls-pemfile", "rustls-pemfile",
"serde",
"serde_json",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tokio-util", "tokio-util",
+2
View File
@@ -26,6 +26,8 @@ hyper-util = { version = "0.1", features = ["tokio", "server", "client"] }
http-body-util = "0.1" http-body-util = "0.1"
http = "1.5.0" http = "1.5.0"
qstring = "0.7.2" qstring = "0.7.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
url = "2" url = "2"
base64 = "0.23.1" base64 = "0.23.1"
+147 -63
View File
@@ -1,18 +1,17 @@
mod cert; mod cert;
mod mitm; mod mitm;
mod tun;
mod mut_qstring; 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::objects::{Global, JClass, JObject, JString};
use jni::sys::{jint, jstring}; use jni::sys::{jboolean, jint};
use jni::errors::ThrowRuntimeExAndDefault;
use jni::{Env, EnvUnowned, JValue, JavaVM, jni_sig, jni_str}; use jni::{Env, EnvUnowned, JValue, JavaVM, jni_sig, jni_str};
use regex::Regex; use regex::Regex;
use std::sync::{OnceLock, RwLock}; use std::sync::{Arc, Mutex, OnceLock};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
static CANCLE_TOKEN: RwLock<Option<CancellationToken>> = RwLock::new(None); static SESSION: Mutex<Option<Session>> = Mutex::new(None);
static JVM: OnceLock<JavaVM> = OnceLock::new(); static JVM: OnceLock<JavaVM> = OnceLock::new();
static VPN_BRIDGE_CLASS: OnceLock<Global<JClass>> = OnceLock::new(); static VPN_BRIDGE_CLASS: OnceLock<Global<JClass>> = OnceLock::new();
@@ -80,101 +79,186 @@ macro_rules! error {
($($arg:tt)*) => {$crate::log_to_kotlin("error", &format!($($arg)*))}; ($($arg:tt)*) => {$crate::log_to_kotlin("error", &format!($($arg)*))};
} }
struct Session {
cancel: CancellationToken,
waiters: Waiters,
done: std::sync::mpsc::Receiver<()>,
thread: Option<std::thread::JoinHandle<()>>,
}
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)] #[allow(non_snake_case)]
#[unsafe(no_mangle)] #[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, mut env: EnvUnowned,
_class: JClass, _class: JClass,
tun_fd: jint, tun_fd: jint,
target_host: JString, steps_json: JString,
target_path: JString,
save_path: JString,
proxy: JString, proxy: JString,
) -> jstring { ) -> jboolean {
let _ = env.with_env(|env: &mut Env| -> Result<(), jni::errors::Error> { let _ = env.with_env(|env: &mut Env| -> Result<(), jni::errors::Error> {
init_jvm(env); init_jvm(env);
Ok(()) Ok(())
}); });
if SESSION.lock().unwrap().is_some() {
let host_arg: String = target_host.to_string(); error!("会话已在运行,忽略重复 boot");
let path: String = target_path.to_string(); return false;
let out: String = save_path.to_string();
let hosts: Vec<String> = 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();
} }
let re = match Regex::new(&path) { let steps_raw: String = steps_json.to_string();
Ok(re) => re,
Err(e) => {
error!("Invalid regex pattern: {path} ({e})");
return std::ptr::null_mut();
}
};
let proxy_raw: String = if proxy.is_null() { let proxy_raw: String = if proxy.is_null() {
String::new() String::new()
} else { } else {
proxy.to_string() proxy.to_string()
}; };
let steps: Vec<StepSpec> = 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) { let proxy_cfg = match ProxyConfig::parse(&proxy_raw) {
Ok(cfg) => cfg, Ok(cfg) => cfg,
Err(e) => { Err(e) => {
error!("代理配置无效: {e}"); error!("代理配置无效: {e}");
return std::ptr::null_mut(); return false;
} }
}; };
if let Some(cfg) = &proxy_cfg { 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 mut hosts: Vec<String> = Vec::new();
let mut waiter_list: Vec<Waiter> = Vec::new();
for step in steps {
let re = match Regex::new(&step.path) {
Ok(re) => re,
Err(e) => {
error!("步骤 {} 的正则不合法: {e}", step.id);
return false;
}
};
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;
}
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(); let rt = tokio::runtime::Runtime::new().unwrap();
let cancel_token = CancellationToken::new(); if let Err(e) = rt.block_on(run_session(
*CANCLE_TOKEN.write().unwrap() = Some(cancel_token.clone()); tun_fd,
hosts,
let cancel_token_clone = cancel_token.clone(); proxy_cfg,
let result = rt.block_on(async move { thread_waiters,
run_capture_async(tun_fd, hosts, re, out, proxy_cfg, cancel_token_clone).await thread_cancel,
}); )) {
cancel_token.cancel(); error!("session error: {e:?}");
}
rt.shutdown_timeout(std::time::Duration::from_secs(2)); rt.shutdown_timeout(std::time::Duration::from_secs(2));
let _ = done_tx.send(());
}) {
Ok(t) => t,
Err(e) => {
error!("启动 session 线程失败: {e}");
return false;
}
};
let url = result.unwrap_or_else(|e| { *SESSION.lock().unwrap() = Some(Session {
error!("capture failed: {e:?}"); cancel,
None waiters,
done: done_rx,
thread: Some(thread),
}); });
let obj: JObject = env true
.with_env(|env: &mut Env| -> Result<JObject, jni::errors::Error> {
match &url {
Some(url) => {
let s = env.new_string(url)?;
Ok(unsafe { JObject::from_raw(env, s.as_raw()) })
}
None => Ok(JObject::null()),
}
})
.resolve::<ThrowRuntimeExAndDefault>();
obj.into_raw()
} }
#[allow(non_snake_case)] #[allow(non_snake_case)]
#[unsafe(no_mangle)] #[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, _env: EnvUnowned,
_class: JClass, _class: JClass,
) { ) {
info!("Stopping capture..."); info!("session stop requested");
if let Some(token) = CANCLE_TOKEN.write().unwrap().take() { if let Some(session) = SESSION.lock().unwrap().as_ref() {
token.cancel() 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");
}
+101 -85
View File
@@ -12,12 +12,11 @@ use rustls::RootCertStore;
use std::io; use std::io;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::os::fd::RawFd; use std::os::fd::RawFd;
use std::sync::{Arc, OnceLock}; use std::sync::{Arc, Mutex as StdMutex, OnceLock};
use std::time::Duration; use std::time::Duration;
use http::Uri; use http::Uri;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::UdpSocket; use tokio::net::UdpSocket;
use tokio::sync::Mutex;
use tokio::task::JoinSet; use tokio::task::JoinSet;
use tokio_rustls::StartHandshake; use tokio_rustls::StartHandshake;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
@@ -115,11 +114,9 @@ async fn handle_tls_connection<S>(
dst: SocketAddr, dst: SocketAddr,
upstream: Upstream, upstream: Upstream,
target_hosts: Vec<String>, target_hosts: Vec<String>,
target_path: Regex, waiters: Waiters,
save_path: String,
server_config: Arc<rustls::ServerConfig>, server_config: Arc<rustls::ServerConfig>,
cancel: CancellationToken, cancel: CancellationToken,
result_tx: Arc<Mutex<Option<tokio::sync::oneshot::Sender<String>>>>,
) -> anyhow::Result<()> ) -> anyhow::Result<()>
where where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static, S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
@@ -136,7 +133,6 @@ where
let Some((sni, accepted)) = sniffed else { let Some((sni, accepted)) = sniffed else {
io.rewind(); io.rewind();
io.stop_recording(); io.stop_recording();
// 非 TLS:拿不到 SNI,走代理时只能按 IP CONNECT
return passthrough_cancel_wrapped(io, dst, None, upstream, &cancel).await; return passthrough_cancel_wrapped(io, dst, None, upstream, &cancel).await;
}; };
@@ -154,7 +150,6 @@ where
} }
return r; return r;
} }
info!("Encountered target host.");
io.stop_recording(); io.stop_recording();
let client_tls = StartHandshake::from_parts(accepted, io) let client_tls = StartHandshake::from_parts(accepted, io)
@@ -162,16 +157,13 @@ where
.await?; .await?;
let svc_host = sni.clone().unwrap_or_default(); let svc_host = sni.clone().unwrap_or_default();
let svc_upstream = upstream;
let svc = hyper::service::service_fn(move |req: hyper::Request<Incoming>| { let svc = hyper::service::service_fn(move |req: hyper::Request<Incoming>| {
let host = svc_host.clone(); let host = svc_host.clone();
let path = target_path.clone(); let waiters = waiters.clone();
let save = save_path.clone(); let upstream = upstream.clone();
let result_tx = result_tx.clone();
let upstream = svc_upstream.clone();
async move { async move {
Ok::<_, std::convert::Infallible>( 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; break;
} }
_ = cancel.cancelled(), if !graceful => { _ = cancel.cancelled(), if !graceful => {
info!("teardown: graceful shutdown of downstream connection");
conn.as_mut().graceful_shutdown(); conn.as_mut().graceful_shutdown();
graceful = true; graceful = true;
} }
@@ -218,38 +211,42 @@ where
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
async fn forward_request( async fn forward_request(
req: hyper::Request<Incoming>, mut req: hyper::Request<Incoming>,
dst: SocketAddr, dst: SocketAddr,
url_host: String, url_host: String,
target_path: Regex, waiters: Waiters,
save_path: String,
result_tx: Arc<Mutex<Option<tokio::sync::oneshot::Sender<String>>>>,
upstream: Upstream, upstream: Upstream,
) -> hyper::Response<Full<Bytes>> { ) -> hyper::Response<Full<Bytes>> {
match forward_request_inner(req, dst, &url_host, &target_path, &upstream).await { if let Some(rewritten) = force_full_mysekai(req.uri()) {
Ok((response, matched_body)) => { warn!("rewrite mysekai flag: {} -> {}", req.uri(), rewritten);
if let Some((url, body)) = matched_body { *req.uri_mut() = rewritten;
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; let uri = req.uri().clone();
} let url = build_url(&url_host, &uri);
match tokio::fs::write(path, &body).await { let path_and_query = uri
Ok(()) => { .path_and_query()
if let Some(tx) = result_tx.lock().await.take() { .map(|p| p.as_str().to_string())
let _ = tx.send(url); .unwrap_or_else(|| "/".to_string());
}
} let waiter = take_matching_waiter(&waiters, &path_and_query);
Err(e) => error!("write {save_path} failed: {e:?}"), 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 response
} }
Err(e) => { Err(e) => {
error!("forward to {dst} failed: {e:?}"); error!("forward to {dst} failed: {e:?}");
if let Some(w) = waiter {
crate::notify_step_result(&w.id, None);
}
hyper::Response::builder() hyper::Response::builder()
.status(hyper::StatusCode::BAD_GATEWAY) .status(hyper::StatusCode::BAD_GATEWAY)
.header("content-type", "text/plain; charset=utf-8") .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( async fn forward_request_inner(
req: hyper::Request<Incoming>, req: hyper::Request<Incoming>,
dst: SocketAddr, dst: SocketAddr,
url_host: &str, url_host: &str,
target_path: &Regex, upstream: Upstream,
upstream: &Upstream, want_body: bool,
) -> anyhow::Result<(hyper::Response<Full<Bytes>>, Option<(String, Bytes)>)> { ) -> anyhow::Result<(hyper::Response<Full<Bytes>>, Option<Bytes>)> {
let (mut parts, body) = req.into_parts(); 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(); let body_bytes = body.collect().await?.to_bytes();
// 拒绝长链接,反正抓到就跑。 // 拒绝长链接
for h in [ for h in [
"connection", "connection",
"keep-alive", "keep-alive",
@@ -297,7 +305,6 @@ async fn forward_request_inner(
let mut client_config = rustls::ClientConfig::builder() let mut client_config = rustls::ClientConfig::builder()
.with_root_certificates(root_store()) .with_root_certificates(root_store())
.with_no_client_auth(); .with_no_client_auth();
// 问就是有服务器不接受 H2
client_config.alpn_protocols = vec![b"http/1.1".to_vec()]; client_config.alpn_protocols = vec![b"http/1.1".to_vec()];
let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config)); let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config));
let server_name = rustls::pki_types::ServerName::try_from(url_host.to_string())?; 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); res_parts.headers.remove(h);
} }
let is_target = target_path.is_match(&path_and_query); let saved = if want_body {
let url = build_url(url_host, &parts_uri); Some(res_bytes.clone())
let save = if is_target {
Some((url, res_bytes.clone()))
} else { } else {
None None
}; };
Ok(( Ok((
hyper::Response::from_parts(res_parts, Full::new(res_bytes)), hyper::Response::from_parts(res_parts, Full::new(res_bytes)),
save, saved,
)) ))
} }
@@ -362,6 +366,30 @@ fn force_full_mysekai(uri: &Uri) -> Option<Uri> {
} }
#[derive(serde::Deserialize, Debug, Clone)]
pub struct StepSpec {
pub id: String,
#[serde(default)]
pub hosts: Vec<String>,
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<StdMutex<Vec<Waiter>>>;
fn take_matching_waiter(waiters: &StdMutex<Vec<Waiter>>, path_and_query: &str) -> Option<Waiter> {
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)] #[derive(Clone, Debug)]
pub struct ProxyConfig { pub struct ProxyConfig {
pub tls: bool, pub tls: bool,
@@ -500,14 +528,13 @@ async fn connect_via_proxy(
Ok(io) Ok(io)
} }
pub async fn run_capture_async( pub async fn run_session(
tun_fd: i32, tun_fd: i32,
target_hosts: Vec<String>, target_hosts: Vec<String>,
target_path: Regex,
save_path: String,
proxy: Option<ProxyConfig>, proxy: Option<ProxyConfig>,
cancel_token: CancellationToken, waiters: Waiters,
) -> anyhow::Result<Option<String>> { cancel: CancellationToken,
) -> anyhow::Result<()> {
let device = unsafe { BorrowedAsyncDevice::borrow_raw(tun_fd as RawFd)? }; let device = unsafe { BorrowedAsyncDevice::borrow_raw(tun_fd as RawFd)? };
let mut ipstack_config = ipstack::IpStackConfig::default(); let mut ipstack_config = ipstack::IpStackConfig::default();
@@ -520,19 +547,11 @@ pub async fn run_capture_async(
let tls_acceptor = build_tls_acceptor(); let tls_acceptor = build_tls_acceptor();
let upstream = Upstream::new(proxy); let upstream = Upstream::new(proxy);
let (result_tx, mut result_rx) = tokio::sync::oneshot::channel::<String>(); loop {
let result_tx = Arc::new(Mutex::new(Some(result_tx))); let token = cancel.clone();
let result: anyhow::Result<Option<String>> = loop {
let token = cancel_token.clone();
tokio::select! { tokio::select! {
_ = cancel_token.cancelled() => { _ = cancel.cancelled() => {
let final_result = result_rx.try_recv().ok(); break;
break Ok(final_result);
}
Ok(url) = &mut result_rx => {
cancel_token.cancel();
break Ok(Some(url));
} }
_ = bridge_tasks.join_next(), if !bridge_tasks.is_empty() => {} _ = bridge_tasks.join_next(), if !bridge_tasks.is_empty() => {}
@@ -541,7 +560,7 @@ pub async fn run_capture_async(
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
error!("ip_stack accept error (device might be dropped): {e:?}"); error!("ip_stack accept error (device might be dropped): {e:?}");
break Err(anyhow::Error::from(e)); break;
} }
}; };
@@ -564,7 +583,7 @@ pub async fn run_capture_async(
if dst.port() == 443 { if dst.port() == 443 {
let (app_io, mut tun_io) = tokio::io::duplex(256 * 1024); 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 { bridge_tasks.spawn(async move {
tokio::select! { tokio::select! {
_ = pump_token.cancelled() => { _ = pump_token.cancelled() => {
@@ -585,11 +604,9 @@ pub async fn run_capture_async(
}); });
let hosts = target_hosts.clone(); let hosts = target_hosts.clone();
let path = target_path.clone();
let save = save_path.clone();
let server_config = tls_acceptor.clone(); let server_config = tls_acceptor.clone();
let token = cancel_token.clone(); let token = cancel.clone();
let result_tx = result_tx.clone(); let waiters = waiters.clone();
let upstream = upstream.clone(); let upstream = upstream.clone();
tokio::spawn(async move { tokio::spawn(async move {
@@ -598,18 +615,16 @@ pub async fn run_capture_async(
dst, dst,
upstream, upstream,
hosts, hosts,
path, waiters,
save,
server_config, server_config,
token, token,
result_tx
).await { ).await {
Ok(()) => {} Ok(()) => {}
Err(e) => error!("handle tls conn error: {e:?}"), Err(e) => error!("handle tls conn error: {e:?}"),
} }
}); });
} else { } else {
let pump_token = cancel_token.clone(); let pump_token = cancel.clone();
let upstream = upstream.clone(); let upstream = upstream.clone();
bridge_tasks.spawn(async move { bridge_tasks.spawn(async move {
let _ = let _ =
@@ -622,7 +637,7 @@ pub async fn run_capture_async(
} }
} }
} }
}; }
let _ = tokio::time::timeout(TEARDOWN_GRACE, async { let _ = tokio::time::timeout(TEARDOWN_GRACE, async {
while bridge_tasks.join_next().await.is_some() {} while bridge_tasks.join_next().await.is_some() {}
@@ -631,7 +646,8 @@ pub async fn run_capture_async(
bridge_tasks.abort_all(); bridge_tasks.abort_all();
while bridge_tasks.join_next().await.is_some() {} while bridge_tasks.join_next().await.is_some() {}
result info!("session stopped");
Ok(())
} }
async fn tcp_passthrough<IO>( async fn tcp_passthrough<IO>(