1.0 能用

This commit is contained in:
2026-10-24 14:48:59 +08:00
commit eea818f5f4
48 changed files with 4802 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
/.idea
/.kotlin
/.gradle
/build
+3
View File
@@ -0,0 +1,3 @@
/build
/release
/src/main/jniLibs
+46
View File
@@ -0,0 +1,46 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "net.bluemangoo.indpjsk"
compileSdk {
version = release(37)
}
defaultConfig {
applicationId = "net.bluemangoo.indpjsk"
minSdk = 28
targetSdk = 37
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
}
}
dependencies {
implementation("androidx.core:core-ktx:1.19.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
implementation("androidx.activity:activity-compose:1.13.0")
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
debugImplementation("androidx.compose.ui:ui-tooling")
}
+26
View File
@@ -0,0 +1,26 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-keep class net.bluemangoo.indpjsk.vpn.VpnBridge { *; }
-keepclasseswithmembernames class * {
native <methods>;
}
+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.IndPjsk">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.IndPjsk">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<service
android:name=".vpn.CaptureVpnService"
android:exported="true"
android:permission="android.permission.BIND_VPN_SERVICE" tools:ignore="VpnServicePolicy">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,83 @@
package net.bluemangoo.indpjsk
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.net.VpnService
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.content.FileProvider
import net.bluemangoo.indpjsk.ui.CaptureScreen
import net.bluemangoo.indpjsk.ui.CaptureViewModel
import net.bluemangoo.indpjsk.ui.theme.IndPjskTheme
import java.io.File
class MainActivity : ComponentActivity() {
private val viewModel: CaptureViewModel by viewModels()
private val vpnPermissionLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) {
viewModel.startCapture(this)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
IndPjskTheme {
CaptureScreen(
viewModel = viewModel,
onStartCapture = { requestVpnAndStart() },
onStopCapture = { viewModel.stopCapture(this) },
onOpenFile = { openCapturedFile(it) }
)
}
}
}
private fun requestVpnAndStart() {
val prepare: Intent? = VpnService.prepare(this)
if (prepare != null) {
vpnPermissionLauncher.launch(prepare)
} else {
viewModel.startCapture(this)
}
}
private fun openCapturedFile(file: File) {
if (!file.exists()) return
val authority = "$packageName.fileprovider"
val uri: Uri = FileProvider.getUriForFile(this, authority, file)
val mime = mimeTypeFor(file.extension)
val viewIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mime)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
startActivity(Intent.createChooser(viewIntent, "打开捕获文件"))
} catch (_: ActivityNotFoundException) {
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = mime
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(shareIntent, "分享捕获文件"))
}
}
private fun mimeTypeFor(ext: String?): String = when (ext?.lowercase()) {
"json" -> "application/json"
"txt", "log" -> "text/plain"
"xml" -> "application/xml"
"html", "htm" -> "text/html"
else -> "application/octet-stream"
}
}
@@ -0,0 +1,34 @@
package net.bluemangoo.indpjsk.data
import java.io.File
data class CaptureProfile(
val id: String,
val name: String,
val steps: List<CaptureProfileStep>
)
data class CaptureProfileStep(
val id: String,
val name: String,
/** 该步骤可能命中的上游域名;TW/KR 的 cdn / https 两个域名都会出现 */
val hosts: List<String>,
/** 匹配 path + query 的正则(Rust 侧 Regex::is_match) */
val path: String,
val fileName: String,
/**
* 该步骤抓成功后异步执行(不阻塞下一步的监听)。
* 没有填上传码时会走这里;参数是本次抓包的结果。
*/
val postAction: suspend (CaptureResult) -> Unit = {},
)
/** 一步抓包完成后的结果,交给 postAction 用 */
data class CaptureResult(
val profile: CaptureProfile,
val step: CaptureProfileStep,
/** 命中的原始完整 URL(https://host/path?query,MySekai 的 flag 已改写为 True) */
val url: String,
/** 抓到的响应体文件 */
val file: File,
)
@@ -0,0 +1,76 @@
package net.bluemangoo.indpjsk.data
object PredefinedProfiles {
/**
* 两个数据接口的正则(Host 已去掉,Rust 侧是拿 path + query 去匹配的):
*
* 1. Suite(个人信息 / 卡牌 / 仓库主数据)
* Path 模板: /api/suite/user/{uid}
*
* 2. MySekai(烤森房间 / 地图全量数据)
* Path 模板: /api/user/{uid}/mysekai?isForceAllReloadOnlyMysekai=True
*/
private const val SUITE_PATH = "/api/suite/user/(\\d+)(\\?isLogin=true)?\$"
private const val MYSEKAI_PATH =
"/api/user/(\\d+)/mysekai\\?isForceAllReloadOnlyMysekai=True"
private fun suiteStep(hosts: List<String>) = CaptureProfileStep(
id = "suite",
name = "Suite",
hosts = hosts,
path = SUITE_PATH,
fileName = "suite"
)
private fun mysekaiStep(hosts: List<String>) = CaptureProfileStep(
id = "mysekai",
name = "烤森",
hosts = hosts,
path = MYSEKAI_PATH,
fileName = "mysekai"
)
private val CN_HOSTS = listOf("mkcn-prod-public-60001-1.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(
"mk-zian-obt-cdn.bytedgame.com",
"mk-zian-obt-https.bytedgame.com"
)
private val KR_HOSTS = listOf(
"mkkorea-obt-prod01-cdn.bytedgame.com",
"mkkorea-obt-prod01-https.bytedgame.com"
)
val all: List<CaptureProfile> = listOf(
// 国服不需要烤森
CaptureProfile(
id = "cn",
name = "国服",
steps = listOf(suiteStep(CN_HOSTS))
),
CaptureProfile(
id = "jp",
name = "日服",
steps = listOf(suiteStep(JP_HOSTS), mysekaiStep(JP_HOSTS))
),
CaptureProfile(
id = "en",
name = "国际服",
steps = listOf(suiteStep(EN_HOSTS), mysekaiStep(EN_HOSTS))
),
CaptureProfile(
id = "tw",
name = "台服",
steps = listOf(suiteStep(TW_HOSTS), mysekaiStep(TW_HOSTS))
),
CaptureProfile(
id = "kr",
name = "韩服",
steps = listOf(suiteStep(KR_HOSTS), mysekaiStep(KR_HOSTS))
),
)
fun byId(id: String): CaptureProfile? = all.firstOrNull { it.id == id }
}
@@ -0,0 +1,23 @@
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"
fun loadUploadCode(context: Context): String =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_UPLOAD_CODE, "")
.orEmpty()
fun saveUploadCode(context: Context, code: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit {
putString(KEY_UPLOAD_CODE, code)
}
}
}
@@ -0,0 +1,54 @@
package net.bluemangoo.indpjsk.data
import android.content.Context
import org.json.JSONObject
import androidx.core.content.edit
/**
* 每个 profile 下每个步骤「要不要抓」的持久化。
*
* 就一个 JSON 字符串塞进 SharedPreferences,键是 "profileId/stepId",值 Boolean,
* 没存过的默认 true(全抓)。
*/
object StepSelectionStore {
private const val PREFS = "step_selection"
private const val KEY_JSON = "json"
fun key(profileId: String, stepId: String) = "$profileId/$stepId"
fun loadAll(context: Context): MutableMap<String, Boolean> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_JSON, null) ?: return mutableMapOf()
return try {
val json = JSONObject(raw)
val out = mutableMapOf<String, Boolean>()
json.keys().forEach { k -> out[k] = json.optBoolean(k, true) }
out
} catch (_: Throwable) {
mutableMapOf()
}
}
fun isEnabled(all: Map<String, Boolean>, profileId: String, stepId: String): Boolean =
all[key(profileId, stepId)] ?: true
fun saveAll(context: Context, all: Map<String, Boolean>) {
val json = JSONObject()
all.forEach { (k, v) -> json.put(k, v) }
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit { putString(KEY_JSON, json.toString()) }
}
fun setEnabled(
context: Context,
profileId: String,
stepId: String,
enabled: Boolean
): MutableMap<String, Boolean> {
val all = loadAll(context)
all[key(profileId, stepId)] = enabled
saveAll(context, all)
return all
}
}
@@ -0,0 +1,66 @@
package net.bluemangoo.indpjsk.state
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import net.bluemangoo.indpjsk.data.CaptureProfile
import net.bluemangoo.indpjsk.data.PredefinedProfiles
import java.io.File
enum class CaptureStatus { IDLE, RUNNING, CAPTURED }
data class CaptureState(
val profile: CaptureProfile,
val status: CaptureStatus = CaptureStatus.IDLE,
val lastCapturedFile: File? = null,
val errorMessage: String? = null
)
object CaptureStateHolder {
private val _state = MutableStateFlow(
CaptureState(profile = PredefinedProfiles.all.first())
)
val state: StateFlow<CaptureState> = _state.asStateFlow()
@Synchronized
fun selectProfile(profile: CaptureProfile) {
if (_state.value.status == CaptureStatus.RUNNING) return
_state.value = CaptureState(profile = profile)
}
@Synchronized
fun setRunning() {
_state.value = _state.value.copy(
status = CaptureStatus.RUNNING,
lastCapturedFile = null,
errorMessage = null
)
}
@Synchronized
fun setCaptured(file: File) {
_state.value = _state.value.copy(
status = CaptureStatus.CAPTURED,
lastCapturedFile = file,
errorMessage = null
)
}
@Synchronized
fun setIdle() {
_state.value = _state.value.copy(
status = CaptureStatus.IDLE,
lastCapturedFile = null,
errorMessage = null
)
}
@Synchronized
fun setError(message: String) {
_state.value = _state.value.copy(
status = CaptureStatus.IDLE,
errorMessage = message
)
}
}
@@ -0,0 +1,331 @@
package net.bluemangoo.indpjsk.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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
import net.bluemangoo.indpjsk.vpn.LogLevel
import net.bluemangoo.indpjsk.vpn.VpnLog
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CaptureScreen(
viewModel: CaptureViewModel,
onStartCapture: () -> Unit,
onStopCapture: () -> Unit,
onOpenFile: (File) -> Unit
) {
val state by viewModel.uiState.collectAsState()
val logs by VpnLog.logs.collectAsState()
var dropdownExpanded by remember { mutableStateOf(false) }
val context = LocalContext.current
var enabledMap by remember { mutableStateOf(StepSelectionStore.loadAll(context) as Map<String, Boolean>) }
var uploadCode by remember { mutableStateOf(SettingsStore.loadUploadCode(context)) }
val isRunning = state.status == CaptureStatus.RUNNING
val isCaptured = state.status == CaptureStatus.CAPTURED
val selected = state.selectedProfile
Scaffold(
topBar = {
TopAppBar(
title = { Text("咖喱烧烤无ROOT抓包") },
actions = {
IconButton(onClick = { VpnLog.clear() }) {
Icon(
painter = painterResource(id = R.drawable.delete_24px),
contentDescription = "清空日志"
)
}
Box {
TextButton(
onClick = { dropdownExpanded = true },
enabled = !isRunning
) {
Text(
text = selected?.name ?: "无配置",
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Icon(
painter = painterResource(id = R.drawable.arrow_drop_down_24px),
contentDescription = null
)
}
DropdownMenu(
expanded = dropdownExpanded,
onDismissRequest = { dropdownExpanded = false }
) {
state.profiles.forEach { profile ->
DropdownMenuItem(
text = { Text(profile.name) },
onClick = {
viewModel.selectProfile(profile)
dropdownExpanded = false
}
)
}
}
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = uploadCode,
onValueChange = {
uploadCode = it.trim()
SettingsStore.saveUploadCode(context, uploadCode)
},
modifier = Modifier.fillMaxWidth(),
enabled = !isRunning,
singleLine = true,
label = { Text("上传码(从 Haruki 工具箱 - 数据上传 - iOS 获取)") },
textStyle = MaterialTheme.typography.bodyMedium,
visualTransformation = PasswordVisualTransformation()
)
if (selected != null) {
StepSelector(
profile = selected,
enabledMap = enabledMap,
editable = !isRunning,
onToggle = { step, checked ->
enabledMap = StepSelectionStore.setEnabled(
context, selected.id, step.id, checked
)
}
)
}
LogPanel(
logs = logs,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
)
StatusLine(status = state.status, error = state.errorMessage)
Button(
onClick = if (isRunning) onStopCapture else onStartCapture,
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
colors = if (isRunning) {
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
} else ButtonDefaults.buttonColors()
) {
Icon(
painter = painterResource(
id = if (isRunning) R.drawable.stop_24px else R.drawable.play_arrow_24px
),
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text(if (isRunning) "停止监听" else "启动抓包")
}
OutlinedButton(
onClick = { state.lastCapturedFile?.let(onOpenFile) },
enabled = isCaptured && state.lastCapturedFile != null,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
) {
Icon(
painter = painterResource(id = R.drawable.file_open_24px),
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text("打开捕获文件")
}
}
}
}
@Composable
private fun StepSelector(
profile: CaptureProfile,
enabledMap: Map<String, Boolean>,
editable: Boolean,
onToggle: (CaptureProfileStep, Boolean) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp)
)
.padding(horizontal = 10.dp, vertical = 8.dp)
) {
Text(
text = "抓包步骤",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
profile.steps.forEach { step ->
val checked = StepSelectionStore.isEnabled(enabledMap, profile.id, step.id)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = checked,
onCheckedChange = { onToggle(step, it) },
enabled = editable
)
Column(modifier = Modifier.weight(1f)) {
Text(step.name, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
@Composable
private fun LogPanel(logs: List<LogEntry>, modifier: Modifier = Modifier) {
val listState = rememberLazyListState()
LaunchedEffect(logs.size) {
if (logs.isNotEmpty()) listState.animateScrollToItem(logs.lastIndex)
}
Box(
modifier = modifier.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp)
)
) {
if (logs.isEmpty()) {
Text(
text = "暂无日志,点击下方按钮开始抓包",
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
items(logs) { entry -> LogRow(entry) }
}
}
}
}
@Composable
private fun LogRow(entry: LogEntry) {
val color = when (entry.level) {
LogLevel.ERROR -> MaterialTheme.colorScheme.error
LogLevel.WARN -> Color(0xFFB26A00)
LogLevel.SUCCESS -> Color(0xFF2E7D32)
LogLevel.INFO -> MaterialTheme.colorScheme.onSurfaceVariant
}
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = entry.time,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Spacer(Modifier.width(6.dp))
Text(
text = entry.message,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = color
)
}
}
@Composable
private fun StatusLine(status: CaptureStatus, error: String?) {
val text = when (status) {
CaptureStatus.IDLE -> if (error != null) "失败:$error" else "就绪"
CaptureStatus.RUNNING -> "正在监听目标流量…"
CaptureStatus.CAPTURED -> "抓包成功,可打开文件"
}
val color = when (status) {
CaptureStatus.RUNNING -> MaterialTheme.colorScheme.primary
CaptureStatus.CAPTURED -> Color(0xFF2E7D32)
CaptureStatus.IDLE ->
if (error != null) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant
}
Text(
text = text,
color = color,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
HorizontalDivider()
}
@@ -0,0 +1,70 @@
package net.bluemangoo.indpjsk.ui
import android.content.Context
import android.content.Intent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import net.bluemangoo.indpjsk.data.CaptureProfile
import net.bluemangoo.indpjsk.data.PredefinedProfiles
import net.bluemangoo.indpjsk.state.CaptureStateHolder
import net.bluemangoo.indpjsk.state.CaptureStatus
import net.bluemangoo.indpjsk.vpn.CaptureVpnService
import net.bluemangoo.indpjsk.vpn.VpnLog
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.io.File
data class CaptureUiState(
val profiles: List<CaptureProfile> = emptyList(),
val selectedProfile: CaptureProfile? = null,
val status: CaptureStatus = CaptureStatus.IDLE,
val lastCapturedFile: File? = null,
val errorMessage: String? = null
)
class CaptureViewModel : ViewModel() {
val uiState: StateFlow<CaptureUiState> = CaptureStateHolder.state
.map { s ->
CaptureUiState(
profiles = PredefinedProfiles.all,
selectedProfile = s.profile,
status = s.status,
lastCapturedFile = s.lastCapturedFile,
errorMessage = s.errorMessage
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = CaptureUiState(
profiles = PredefinedProfiles.all,
selectedProfile = PredefinedProfiles.all.firstOrNull()
)
)
fun selectProfile(profile: CaptureProfile) {
CaptureStateHolder.selectProfile(profile)
}
fun startCapture(context: Context) {
val profile = uiState.value.selectedProfile ?: return
VpnLog.clear()
CaptureStateHolder.setRunning()
val intent = Intent(context, CaptureVpnService::class.java).apply {
action = CaptureVpnService.ACTION_START
putExtra(CaptureVpnService.EXTRA_PROFILE_ID, profile.id)
}
context.startService(intent)
}
fun stopCapture(context: Context) {
val intent = Intent(context, CaptureVpnService::class.java).apply {
action = CaptureVpnService.ACTION_STOP
}
context.startService(intent)
}
}
@@ -0,0 +1,11 @@
package net.bluemangoo.indpjsk.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,53 @@
package net.bluemangoo.indpjsk.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun IndPjskTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,34 @@
package net.bluemangoo.indpjsk.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
@@ -0,0 +1,107 @@
package net.bluemangoo.indpjsk.upload
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.bluemangoo.indpjsk.vpn.VpnLog
import java.io.File
import java.io.IOException
import java.io.RandomAccessFile
import java.net.HttpURLConnection
import java.net.URL
import kotlin.random.Random
/**
* 把抓到的响应体上传到工具箱:
*
* POST https://toolbox-api-direct.haruki.seiunx.com/ios/script/{upload_code}/upload
* Content-Type: application/octet-stream (原始二进制,每片最大 1MB)
* X-Script-Version: 1.0.1
* X-Upload-Code: {upload_code}
* X-Upload-Id: 9 位随机字母数字(同一文件的所有分片共用)
* X-Chunk-Index: "0", "1", ...
* X-Total-Chunks: 分片总数(ceil(size / 1MB))
* X-Original-Url: 抓到的原始游戏 URL(后端用它识别区服 / UID / 数据类型)
*
* 分片串行发送,每一片都必须返回 HTTP 200。
*/
object Uploader {
private const val BASE = "https://toolbox-api-direct.haruki.seiunx.com/ios/script"
private const val CHUNK_SIZE = 1024 * 1024
private const val SCRIPT_VERSION = "1.0.1"
suspend fun upload(
uploadCode: String,
originalUrl: String,
file: File,
) = withContext(Dispatchers.IO) {
require(file.exists()) { "文件不存在: ${file.absolutePath}" }
val uploadId = randomUploadId()
RandomAccessFile(file, "r").use { raf ->
val size = raf.length()
val total = if (size == 0L) 1 else ((size + CHUNK_SIZE - 1) / CHUNK_SIZE).toInt()
val buf = ByteArray(CHUNK_SIZE)
VpnLog.info("${file.name} 上传开始")
for (index in 0 until total) {
val remaining = size - raf.filePointer
val len = if (remaining <= 0L) 0 else minOf(CHUNK_SIZE.toLong(), remaining).toInt()
if (len > 0) raf.readFully(buf, 0, len)
val code = postChunk(uploadCode, uploadId, index, total, originalUrl, buf, len)
if (code != 200) throw IOException("${file.name} 上传失败: HTTP $code")
}
VpnLog.info("${file.name} 上传完成")
}
}
private fun postChunk(
uploadCode: String,
uploadId: String,
index: Int,
total: Int,
originalUrl: String,
data: ByteArray,
length: Int,
): Int {
val conn = URL("$BASE/$uploadCode/upload").openConnection() as HttpURLConnection
try {
conn.requestMethod = "POST"
conn.doOutput = true
conn.instanceFollowRedirects = false
conn.connectTimeout = 15_000
conn.readTimeout = 60_000
conn.setRequestProperty("Content-Type", "application/octet-stream")
conn.setRequestProperty("X-Script-Version", SCRIPT_VERSION)
conn.setRequestProperty("X-Upload-Code", uploadCode)
conn.setRequestProperty("X-Upload-Id", uploadId)
conn.setRequestProperty("X-Chunk-Index", index.toString())
conn.setRequestProperty("X-Total-Chunks", total.toString())
conn.setRequestProperty("X-Original-Url", originalUrl)
conn.setFixedLengthStreamingMode(length)
conn.outputStream.use { it.write(data, 0, length) }
val code = conn.responseCode
if (code != 200) {
val body = runCatching {
(conn.errorStream ?: conn.inputStream)
?.bufferedReader()
?.use { it.readText() }
?.take(300)
}.getOrNull().orEmpty()
throw IOException("分片 ${index + 1}/$total 上传失败: HTTP $code $body")
}
return code
} finally {
conn.disconnect()
}
}
/** 9 位随机字母数字,同一文件所有分片共用一个 */
private fun randomUploadId(): String {
val chars = "0123456789abcdefghijklmnopqrstuvwxyz"
return buildString(9) {
repeat(9) { append(chars[Random.nextInt(chars.length)]) }
}
}
}
@@ -0,0 +1,305 @@
package net.bluemangoo.indpjsk.vpn
import android.annotation.SuppressLint
import android.content.Intent
import android.net.VpnService
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import net.bluemangoo.indpjsk.data.CaptureProfile
import net.bluemangoo.indpjsk.data.CaptureProfileStep
import net.bluemangoo.indpjsk.data.CaptureResult
import net.bluemangoo.indpjsk.data.PredefinedProfiles
import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.data.StepSelectionStore
import net.bluemangoo.indpjsk.state.CaptureStateHolder
import net.bluemangoo.indpjsk.upload.Uploader
import java.io.File
/**
* 多步骤抓包:
* establish() 只做一次,同一个 tun fd 按顺序喂给每一步,中间不拆 VPN。
* 每一步抓到后异步跑它的 postAction(不阻塞下一步的监听)。
*
* 停止机制:
* ACTION_STOP → requestStop() + close(activePfd),当前步骤的 runCapture 返回 null,
* 循环发现 stopRequested 后终止后续步骤。
*
* fd 所有权:
* 全生命周期在 Android 侧持有。传给 Rust 的是 pfd.fd(int 缓存值),
* Rust 内部用 BorrowedAsyncDevice 借用,不做任何 close。
* Android 在 finally 里统一 close(activePfd)。
*/
@SuppressLint("VpnServicePolicy")
class CaptureVpnService : VpnService() {
companion object {
const val ACTION_START = "net.bluemangoo.indpjsk.START"
const val ACTION_STOP = "net.bluemangoo.indpjsk.STOP"
const val EXTRA_PROFILE_ID = "extra_profile_id"
private const val VPN_ADDRESS = "10.7.0.2"
private const val VPN_PREFIX = 32
/**
* 后处理(上传)用的 scope:进程级,故意不随 Service 销毁取消。
* 否则 stopSelf() → onDestroy 会把还在上传的分片掐掉(Job was cancelled)。
*/
private val postScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/** 捕获文件统一放在 filesDir/captures/<profileId>/ 下(file_paths.xml 的 FileProvider 路径) */
fun captureFile(
filesDir: File,
profile: CaptureProfile,
step: CaptureProfileStep
): File = File(File(File(filesDir, "captures"), profile.id), step.fileName)
}
/** 工作线程持有;供 ACTION_STOP 从主线程关闭以中断 Rust */
@Volatile
private var activePfd: ParcelFileDescriptor? = null
@Volatile
private var captureThread: Thread? = null
/** 区分"用户主动停止"与"抓到包后自然结束/失败" */
@Volatile
private var stopRequested = false
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> {
val id = intent.getStringExtra(EXTRA_PROFILE_ID) ?: return START_NOT_STICKY
val profile = PredefinedProfiles.byId(id) ?: return START_NOT_STICKY
startCapture(profile)
}
ACTION_STOP -> {
stopRequested = true
VpnLog.warn("手动停止…")
VpnBridge.requestStop()
closeActivePfd()
// 立即把 UI 从 RUNNING 拉回来;capture 线程退出时还会再写一次
CaptureStateHolder.setIdle()
stopSelf()
}
else -> return START_NOT_STICKY
}
return START_NOT_STICKY
}
private fun closeActivePfd() {
val pfd = activePfd
activePfd = null
try {
pfd?.close()
VpnBridge.requestStop()
} catch (t: Throwable) {
VpnLog.warn("关闭代理失败: ${t.message}")
}
}
private fun startCapture(profile: CaptureProfile) {
if (captureThread?.isAlive == true) {
VpnLog.warn("抓包已在运行,忽略重复启动")
return
}
// 按 profile 里的顺序,过滤掉没勾选的步骤(默认全勾)
val savedSelection = StepSelectionStore.loadAll(this)
val enabledSteps = profile.steps.filter { step ->
StepSelectionStore.isEnabled(savedSelection, profile.id, step.id)
}
if (enabledSteps.isEmpty()) {
VpnLog.error("没有勾选任何步骤")
CaptureStateHolder.setError("没有勾选任何步骤")
return
}
// 上传码在开始时快照,运行中改 UI 不影响本次
val uploadCode = SettingsStore.loadUploadCode(this).trim()
if (uploadCode.isNotEmpty() && uploadCode.length != 32) {
VpnLog.warn("上传码长度不是 32 位(当前 ${uploadCode.length} 位),上传大概率会失败")
}
stopRequested = false
VpnLog.info("==== 开始抓包 ====")
VpnLog.info("配置: ${profile.name}")
VpnLog.info("步骤: ${enabledSteps.joinToString(" -> ") { it.name }}")
captureThread = Thread({
var pfd: ParcelFileDescriptor?
var lastFile: File? = null
var completed = 0
var errorMsg: String? = null
try {
if (!VpnBridge.isAvailable()) {
throw IllegalStateException("Rust 动态库 core_vpn 未加载")
}
// 2) 建立 VPN:仅 /32 目标路由 + 排除自身(防止 Rust 转发流量回环)
val builder = Builder()
.setSession("Capture-${profile.id}")
.addAddress(VPN_ADDRESS, VPN_PREFIX)
.addDisallowedApplication(packageName)// 1.0.0.0/8 (排除 0.0.0.0/8 本地识别地址)
.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("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("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("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")
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 label = "[${index + 1}/${enabledSteps.size}] ${step.name}"
val target = captureFile(filesDir, profile, step)
target.parentFile?.mkdirs()
if (target.exists()) target.delete()
// 阻塞调用 Rust,直到抓到本步骤的目标请求(或停止 / 出错)
val capturedUrl = VpnBridge.runCapture(
fd,
step.hosts.joinToString(","),
step.path,
target.absolutePath
)
if (capturedUrl == null) {
if (stopRequested) {
VpnLog.warn("$label 被用户停止")
break
}
errorMsg = "$label 未抓到目标请求"
VpnLog.error(errorMsg)
break
}
if (!target.exists() || target.length() == 0L) {
errorMsg = "$label 返回了 URL 但文件为空"
VpnLog.error(errorMsg)
break
}
lastFile = target
completed++
VpnLog.success("$label 抓取成功")
// 抓到后异步做后处理(上传 / 该步骤自己的 postAction),不阻塞下一步监听
val result = CaptureResult(profile, step, capturedUrl, target)
postScope.launch {
try {
if (uploadCode.isNotEmpty()) {
VpnLog.info("$label 开始上传")
Uploader.upload(uploadCode, capturedUrl, target)
VpnLog.success("${profile.name}${step.name} 上传完成")
} else {
step.postAction(result)
}
} catch (c: CancellationException) {
VpnLog.warn("$label 后处理被取消: ${c.message}")
throw c
} catch (t: Throwable) {
VpnLog.error("$label 后处理失败: ${t.message}")
}
}
}
} catch (t: Throwable) {
errorMsg = t.message ?: "抓包异常"
VpnLog.error("抓包异常: ${t.message}")
} finally {
// 关闭 fd(若 ACTION_STOP 已关,这次 close 是幂等的/会抛可忽略异常)
closeActivePfd()
VpnLog.info("==== 抓包结束($completed/${enabledSteps.size} 步完成)====")
if (errorMsg != null) {
CaptureStateHolder.setError(errorMsg)
} else if (completed == enabledSteps.size && lastFile != null) {
CaptureStateHolder.setCaptured(lastFile)
} else {
CaptureStateHolder.setIdle()
}
stopSelf()
}
}, "capture-blocking-worker").apply { start() }
}
override fun onRevoke() {
stopRequested = true
VpnLog.warn("VPN 权限被撤销")
closeActivePfd()
super.onRevoke()
}
override fun onDestroy() {
closeActivePfd()
captureThread = null
super.onDestroy()
}
}
@@ -0,0 +1,62 @@
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) -> String?
* - 阻塞直到抓包结束
* - tunFd 的所有权【仍在 Kotlin 侧】,Rust 内部用 BorrowedAsyncDevice 借用,不得 close()
* - targetHosts 是逗号分隔的域名白名单(一个步骤可能对应多个上游域名)
* - 返回非 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
init {
loaded = try {
System.loadLibrary("core_vpn")
true
} catch (t: Throwable) {
Log.e(TAG, "loadLibrary core_vpn failed", t)
false
}
}
fun isAvailable(): Boolean = loaded
@JvmStatic
external fun runCapture(
tunFd: Int,
targetHosts: String,
targetPath: String,
savePath: String
): String?
@JvmStatic
external fun requestStop()
/** 供 Rust JNI 回调写入日志框;Rust 侧通过 call_static_method 调用本方法 */
@Suppress("unused")
@JvmStatic
fun nativeLog(level: String, msg: String) {
when (level) {
"error" -> VpnLog.error(msg)
"warn" -> VpnLog.warn(msg)
"info" -> VpnLog.info(msg)
else -> VpnLog.info(msg)
}
}
}
@@ -0,0 +1,49 @@
package net.bluemangoo.indpjsk.vpn
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import java.time.LocalTime
import java.time.format.DateTimeFormatter
enum class LogLevel { INFO, WARN, ERROR, SUCCESS }
data class LogEntry(
val time: String,
val level: LogLevel,
val message: String
)
/**
* 全局日志容器:
* - Android 侧各处直接调用 info/warn/error/success
* - Rust 侧通过 JNI 静态方法 VpnBridge.nativeLog(String) 回调进来
* - UI 用 LazyColumn 消费 StateFlow,最多保留 MAX 条
*/
object VpnLog {
private const val MAX = 800
private val timeFmt = DateTimeFormatter.ofPattern("HH:mm:ss.SSS")
private val _logs = MutableStateFlow<List<LogEntry>>(emptyList())
val logs: StateFlow<List<LogEntry>> = _logs.asStateFlow()
private fun append(level: LogLevel, msg: String) {
val entry = LogEntry(
time = LocalTime.now().format(timeFmt),
level = level,
message = msg
)
_logs.update { (it + entry).takeLast(MAX) }
}
fun info(msg: String) = append(LogLevel.INFO, msg)
fun warn(msg: String) = append(LogLevel.WARN, msg)
fun error(msg: String) = append(LogLevel.ERROR, msg)
fun success(msg: String) = append(LogLevel.SUCCESS, msg)
fun clear() {
_logs.value = emptyList()
}
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,600L280,400L680,400L480,600Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,560L720,560L720,360L520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L600,800L600,880L240,880ZM878,895L760,777L760,866L680,866L680,640L906,640L906,720L816,720L934,838L878,895ZM240,800L240,560L240,560L240,360L240,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800Z"/>
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#FF000000" android:pathData="M17.6,11.48 L19.44,8.3a0.63,0.63 0,0 0,-1.09 -0.63l-1.88,3.24a11.43,11.43 0,0 0,-8.94 0L5.65,7.67a0.63,0.63 0,0 0,-1.09 0.63L6.4,11.48A10.81,10.81 0,0 0,1 20L23,20A10.81,10.81 0,0 0,17.6 11.48ZM7,17.25A1.25,1.25 0,1 1,8.25 16,1.25 1.25,0 0,1 7,17.25ZM17,17.25A1.25,1.25 0,1 1,18.25 16,1.25 1.25,0 0,1 17,17.25Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M320,760L320,200L760,480L320,760ZM400,480L400,480L400,480ZM400,614L610,480L400,346L400,614Z"/>
</vector>
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M320,320L320,320L320,640L320,640L320,320ZM240,720L240,240L720,240L720,720L240,720ZM320,640L640,640L640,320L320,320L320,640Z"/>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_android_black_24dp" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_android_black_24dp" />
</adaptive-icon>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#00E676</color>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">咖喱烧烤</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.IndPjsk" parent="android:Theme.Material.Light.NoActionBar"/>
</resources>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="captures" path="captures/" />
</paths>
+5
View File
@@ -0,0 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id("com.android.application") version "9.1.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.4.21" apply false
}
+3
View File
@@ -0,0 +1,3 @@
/target
/.cargo
/.idea
+1966
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "core_vpn"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
ipstack = "1.0.1"
tun-rs = { version = "2.8.13", features = ["async_tokio"] }
tokio = { version = "1.47.5", features = ["full"] }
tokio-rustls = "0.26"
rustls-pemfile = "2"
jni = "0.22.4"
rcgen = "0.14"
anyhow = "1"
bytes = "1"
tokio-util = "0.7.20"
rustls = "0.23.46"
rustls-native-certs = "0.8.5"
webpki-roots = "1"
regex = "1.13.1"
hyper = { version = "1.0", features = ["server", "client", "http1"] }
hyper-util = { version = "0.1", features = ["tokio", "server", "client"] }
http-body-util = "0.1"
http = "1.5.0"
[build-dependencies]
rcgen = { version = "0.14", features = ["x509-parser"] }
[profile.release]
lto = true
opt-level = 'z'
codegen-units = 1
strip = true
+156
View File
@@ -0,0 +1,156 @@
use rcgen::{
CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair,
KeyUsagePurpose, SanType,
};
use std::{env, fs, path::PathBuf};
const CA_KEY: &str = "-----BEGIN PRIVATE KEY-----
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAShZANiAAQTglHNUqyS
mMHIqtl3Mh3rl+cJvQtMoKylXcitUdz8nRWJoVl+OlEg4e/WMcY+GDXKyuKYaaYu
FjHoooGBq1ZhbcRdkYq8CfOrDmPPeSqk3O1zh743u6VpVJ8cArJw7Wc=
-----END PRIVATE KEY-----";
const CA_CHAIN: &str = "-----BEGIN PRIVATE KEY-----
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAShZANiAAQTglHNUqyS
mMHIqtl3Mh3rl+cJvQtMoKylXcitUdz8nRWJoVl+OlEg4e/WMcY+GDXKyuKYaaYu
FjHoooGBq1ZhbcRdkYq8CfOrDmPPeSqk3O1zh743u6VpVJ8cArJw7Wc=
-----END PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
MIIDaDCCAu6gAwIBAgIPKWkmwiejoVJjkrGUgqUEMAoGCCqGSM49BAMDMFYxCzAJ
BgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk
MSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM3
NDFaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk
aHJhIEluYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C
AQYFK4EEACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZ
fjpRIOHv1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+
N7ulaVSfHAKycO1no4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L
WUIgkFQwHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMB0GA1UdJQQWMBQG
CCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow
KAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB
BAGDjiEBAm4wCAYGZ4EMAQICMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY
aHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw
b3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNT1ZUTFNDQUczQy5wN2MwNwYDVR0f
BDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j
cmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E
AwMDaAAwZQIxAMSZt2xsPeumRdeSMSQ3CbzIZeQ/q7GDCWfeE1Pq+mhgA6gRM44c
NVF3xJ+wsxJk+wIwFJbaDMokD8Ei6eNOiA8yUMYvfzQyngmrhZQMdPCK1SewrR79
4rSb9vGqxZzelQth
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDdDCCAvmgAwIBAgIQAJYaPvsTWVdu0EAFtGhKWzAKBggqhkjOPQQDAzBrMQsw
CQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhy
YSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290
IENBIC0gRzMwHhcNMjQwNzExMTU0MzM2WhcNMzkwNzA5MTgzMDAwWjBWMQswCQYD
VQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEg
MB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUr
gQQAIgNiAARfD9w/q1cWxL6D9bMGH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8m
WO/RwXQy10DCSxT8vSIJClHKp+Ao4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFw
LKYf8EdDXAyjggF1MIIBcTAfBgNVHSMEGDAWgBR8XQKEE9TMipuBzhccLikenEhj
QjAdBgNVHQ4EFgQU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0lBBYwFAYIKwYB
BQUHAwEGCCsGAQUFBwMCMD0GA1UdIAQ2MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIB
FhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMHgGCCsGAQUFBwEBBGwwajAi
BggrBgEFBQcwAYYWaHR0cDovL29jc3AuZW1zaWduLmNvbTBEBggrBgEFBQcwAoY4
aHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbS9jZXJ0cy9lbVNpZ25FQ0NSb290
Q0FHMy5jcnQwMwYDVR0fBCwwKjAooCagJIYiaHR0cDovL2NybC5lbXNpZ24uY29t
P1Jvb3RDQUczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIB
ATAKBggqhkjOPQQDAwNpADBmAjEAt9CIpRGp46QHQYDUBnLKw+vvK0/h9vmilEAg
TroUl/anH4x2rXHqiCtBTBfgKDegAjEA8WpG1LTZdAIL0FSYqxXeREaeTSxEofM0
1jJH3jGvCXC79LU9UPJ2o3TU+UIj2gMV
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDzzCCAregAwIBAgIRAI1aSBGTnvRH13ERzdmEmukwDQYJKoZIhvcNAQELBQAw
ezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G
A1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV
BAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y
ODEyMzEyMzU5NTlaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu
YzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EE
ACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZfjpRIOHv
1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+N7ulaVSf
HAKycO1no4IBNTCCATEwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQw
HQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMA4GA1UdDwEB/wQEAwIBBjAS
BgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
AjAxBgNVHSAEKjAoMAgGBmeBDAECAjANBgsrBgEEAYOOIQEAATANBgsrBgEEAYOO
IQECbjBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9B
QUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYB
BQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOC
AQEAHlbhZpjhkPEAQVhPHRtBDaHqiisVD20pQ1TUeSx3l62Qe7NQ0b7vWQy7DN0i
KOu7BGpP5EnIjvXbz9FhpfPJ4FhKEt3tQ51rqXz2oD8vRaryvExWPobDUz3j7HN6
OzBsVMIe3PD7AAkpky0x8zMSi/ep5R9OMzybmNptbtppyuB5ytgq+h7lQdRIP+CQ
TaJX3qTgfMqzntYre5GljmEB5hU2/rSn8zoSU2APkJidlZFpghtgbD4I2VRHZvCw
FFr/SrFM9pwrgbJNZ1MtyTSVgI5NlNb0aMfLR+/r6jJt3ROeY2yh2QQ2dx+Yqgdb
uL5ChFWOQU+ErSDnUq7yr+pjBQ==
-----END CERTIFICATE-----";
/// 证书里要覆盖的域名。抓包目标 host 必须在这里面,否则 App 会报证书不受信任。
const SAN: &[&str] = &[
// 国服
"*.dailygn.com",
// 日服
"production-game-api.sekai.colorfulpalette.org",
"*.sekai.colorfulpalette.org",
// 国际服
"n-production-game-api.sekai-en.com",
"*.sekai-en.com",
// 台服 / 韩服(cdn 和 https 两个域都会出现)
"*.bytedgame.com",
"mk-zian-obt-cdn.bytedgame.com",
"mk-zian-obt-https.bytedgame.com",
"mkkorea-obt-prod01-cdn.bytedgame.com",
"mkkorea-obt-prod01-https.bytedgame.com",
];
fn main() {
println!("cargo:rerun-if-changed=build.rs");
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let ca_key = KeyPair::from_pem(CA_KEY).expect("parse CA key failed");
let first_ca_pem = first_pem_certificate(CA_CHAIN);
let issuer = rcgen::Issuer::from_ca_cert_pem(&first_ca_pem, ca_key)
.expect("build Issuer from CA cert + key failed");
let mut leaf_params = CertificateParams::default();
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, SAN[0]);
leaf_params.distinguished_name = dn;
leaf_params.subject_alt_names = SAN
.iter()
.map(|d| SanType::DnsName((*d).try_into().unwrap()))
.collect();
leaf_params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
leaf_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
leaf_params.is_ca = IsCa::NoCa;
// AKI 必须和签发者证书里的 SKI 一致,rcgen 的 Issuer 会自动从 CA 证书里取
leaf_params.use_authority_key_identifier_extension = true;
// 有效期落在 CA 的有效期内(2025-04-30 ~ 2040-04-28)
leaf_params.not_before = rcgen::date_time_ymd(2025, 5, 1);
leaf_params.not_after = rcgen::date_time_ymd(2038, 1, 1);
let leaf_key = KeyPair::generate().expect("generate leaf key failed");
let leaf_cert = leaf_params
.signed_by(&leaf_key, &issuer)
.expect("sign leaf cert failed");
// leaf + 完整 CA 链(两个 G3C 交叉签名都带上,让验证器自己选路径)
let fullchain_pem = format!("{}{}", leaf_cert.pem(), CA_CHAIN);
fs::write(out_dir.join("fullchain.pem"), fullchain_pem).unwrap();
let leaf_key_pem = leaf_key.serialize_pem();
fs::write(out_dir.join("leaf.key"), leaf_key_pem).unwrap();
}
fn first_pem_certificate(pem: &str) -> String {
let begin = "-----BEGIN CERTIFICATE-----";
let end = "-----END CERTIFICATE-----";
if let (Some(s), Some(e)) = (pem.find(begin), pem.find(end)) {
pem[s..e + end.len()].to_string()
} else {
panic!("no certificate found in CA chain PEM");
}
}
+26
View File
@@ -0,0 +1,26 @@
use rustls_pemfile::{certs, private_key};
use std::io::BufReader;
use std::sync::Arc;
pub fn build_tls_acceptor() -> Arc<rustls::ServerConfig> {
let fullchain = include_bytes!(concat!(env!("OUT_DIR"), "/fullchain.pem"));
let leaf_key = include_bytes!(concat!(env!("OUT_DIR"), "/leaf.key"));
let cert_chain = certs(&mut BufReader::new(&fullchain[..]))
.collect::<Result<Vec<_>, _>>()
.unwrap();
let key = private_key(&mut BufReader::new(&leaf_key[..]))
.unwrap()
.unwrap();
let mut config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(cert_chain, key)
.unwrap();
// 真实服务器(dailygn / 拼锁 API)只支持 HTTP/1.1(ALPN 实测不会选 h2),
// 所以这里也只声明 http/1.1,让 App 用 HTTP/1.1 跟我们握手,避免跨协议转换。
config.alpn_protocols = vec![b"http/1.1".to_vec()];
Arc::new(config)
}
+166
View File
@@ -0,0 +1,166 @@
mod cert;
mod mitm;
mod tun;
use crate::mitm::run_capture_async;
use jni::objects::{Global, JClass, JObject, JString};
use jni::sys::{jint, jstring};
use jni::errors::ThrowRuntimeExAndDefault;
use jni::{Env, EnvUnowned, JValue, JavaVM, jni_sig, jni_str};
use regex::Regex;
use std::sync::{OnceLock, RwLock};
use tokio_util::sync::CancellationToken;
static CANCLE_TOKEN: RwLock<Option<CancellationToken>> = RwLock::new(None);
static JVM: OnceLock<JavaVM> = OnceLock::new();
static VPN_BRIDGE_CLASS: OnceLock<Global<JClass>> = OnceLock::new();
fn init_jvm(env: &mut Env) {
if JVM.get().is_some() {
return;
}
if let Ok(vm) = env.get_java_vm() {
// 关键:在调用线程(Java 线程)上 find_class,此时 classloader 正确
if let Ok(class) = env.find_class(jni_str!("net/bluemangoo/indpjsk/vpn/VpnBridge")) {
match env.new_global_ref(class) {
Ok(g) => {
let _ = VPN_BRIDGE_CLASS.set(g);
}
Err(e) => eprintln!("new_global_ref failed: {e:?}"),
}
} else {
eprintln!("find_class failed on init thread");
}
let _ = JVM.set(vm);
}
}
fn log_with_env(env: &mut Env, label: &str, msg: &str) {
let Some(class_ref) = VPN_BRIDGE_CLASS.get() else {
return;
};
let Ok(jlabel) = env.new_string(label) else {
return;
};
let Ok(jmsg) = env.new_string(msg) else {
return;
};
// jni 0.21 中,GlobalRef 可能需要转成 JClass:
let jclass: &JClass = unsafe { &*(class_ref.as_obj() as *const JObject as *const JClass) };
// 或者更安全:let jclass = unsafe { JClass::from_raw(class_ref.as_obj().as_raw()) };
let _ = env.call_static_method(
jclass,
jni_str!("nativeLog"),
jni_sig!("(Ljava/lang/String;Ljava/lang/String;)V"),
&[JValue::Object(&jlabel), JValue::Object(&jmsg)],
);
}
pub fn log_to_kotlin(label: &str, msg: &str) {
let Some(vm) = JVM.get() else {
return;
};
let _ = vm.attach_current_thread(|env: &mut Env| -> Result<(), jni::errors::Error> {
log_with_env(env, label, msg);
Ok(())
});
}
#[macro_export]
macro_rules! info {
($($arg:tt)*) => {$crate::log_to_kotlin("info", &format!($($arg)*))};
}
#[macro_export]
macro_rules! warn {
($($arg:tt)*) => {$crate::log_to_kotlin("warn", &format!($($arg)*))};
}
#[macro_export]
macro_rules! error {
($($arg:tt)*) => {$crate::log_to_kotlin("error", &format!($($arg)*))};
}
#[allow(non_snake_case)]
#[unsafe(no_mangle)]
pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_runCapture(
mut env: EnvUnowned,
_class: JClass,
tun_fd: jint,
target_host: JString,
target_path: JString,
save_path: JString,
) -> jstring {
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<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) {
Ok(re) => re,
Err(e) => {
error!("Invalid regex pattern: {path} ({e})");
return std::ptr::null_mut();
}
};
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, cancel_token_clone).await });
cancel_token.cancel();
// 不要直接 drop runtime:万一有阻塞任务没退干净,drop 会无限等下去,
// JNI 调用就永远不返回(表现为按停止没反应)。设上限,超时就放弃。
rt.shutdown_timeout(std::time::Duration::from_secs(2));
// 抓到目标请求 -> 返回原始完整 URL(https://...?query);否则 null
let url = result.unwrap_or_else(|e| {
error!("capture failed: {e:?}");
None
});
let obj: JObject = env
.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)]
#[unsafe(no_mangle)]
pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_requestStop(
_env: EnvUnowned,
_class: JClass,
) {
info!("Stopping capture...");
if let Some(token) = CANCLE_TOKEN.write().unwrap().take() {
token.cancel()
}
}
+587
View File
@@ -0,0 +1,587 @@
use crate::cert::build_tls_acceptor;
use crate::tun::{BorrowedTunIo, RewindStream};
use crate::{error, info, warn};
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::body::Incoming;
use hyper_util::rt::TokioIo;
use ipstack::IpStackStream;
use regex::Regex;
use rustls::RootCertStore;
use std::io;
use std::net::SocketAddr;
use std::os::fd::RawFd;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
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;
use tun_rs::BorrowedAsyncDevice;
static ROOT_STORE: OnceLock<Arc<RootCertStore>> = OnceLock::new();
/// 嗅探 ClientHello 的最长等待时间。超时后按「非目标」原样透传,
/// 这样「服务器先说话」的协议(客户端连上后不发数据)也不会被卡住。
const SNIFF_TIMEOUT: Duration = Duration::from_secs(5);
const DOH_BLACKLIST: &[&str] = &[
"dns.google",
"dns.google.com",
"cloudflare-dns.com",
"mozilla.cloudflare-dns.com",
"dns.quad9.net",
"dns.adguard.com",
"doh.opendns.com",
"dns.alidns.com", // 阿里 DoH
"doh.pub", // 腾讯 DoH
"dns.alidns.com",
];
fn root_store() -> Arc<RootCertStore> {
ROOT_STORE
.get_or_init(|| {
let mut root_store = RootCertStore::empty();
let native_certs = rustls_native_certs::load_native_certs();
for err in native_certs.errors {
warn!("加载证书警告: {err}");
}
for cert in native_certs.certs {
let _ = root_store.add(cert);
}
load_system_ca_dirs(&mut root_store);
// 兜底:一个根证书都没有时用内置 Mozilla 列表,否则上游必然握手失败
if root_store.is_empty() {
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
}
Arc::new(root_store)
})
.clone()
}
/// 读取系统 CA 目录(Android 的路径,其他平台不存在就走空)
fn load_system_ca_dirs(store: &mut RootCertStore) {
use std::io::BufReader;
const DIRS: &[&str] = &[
"/apex/com.android.conscrypt/cacerts", // Android 14+
"/system/etc/security/cacerts", // 旧版 Android
"/data/misc/user/0/cacerts-added", // 用户自己装的 CA
];
for dir in DIRS {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(file) = std::fs::File::open(entry.path()) else {
continue;
};
for cert in rustls_pemfile::certs(&mut BufReader::new(file)).flatten() {
let _ = store.add(cert);
}
}
}
}
/// 嗅探 TLS ClientHello(不破坏字节流)。
///
/// 返回 `None` 表示对端不是 TLS(明文 HTTP、游戏自定义协议、超时……),
/// 调用方应当调用 `io.rewind()` 之后原样透传,而**不是**断开连接。
async fn sniff_client_hello<S>(
io: &mut RewindStream<S>,
) -> io::Result<Option<(Option<String>, rustls::server::Accepted)>>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let mut acceptor = rustls::server::Acceptor::default();
let mut buf = vec![0u8; 16 * 1024];
loop {
let n = match tokio::time::timeout(SNIFF_TIMEOUT, io.read(&mut buf)).await {
Ok(Ok(n)) => n,
Ok(Err(e)) => return Err(e),
Err(_) => return Ok(None), // 对端迟迟不发数据,当作非 TLS
};
if n == 0 {
return Ok(None); // 还没收到 ClientHello 就 EOF
}
// 只喂给 acceptor 一份拷贝,原始字节仍留在 RewindStream 的 history 里
if acceptor.read_tls(&mut &buf[..n]).is_err() {
return Ok(None);
}
match acceptor.accept() {
Ok(Some(accepted)) => {
let sni = accepted.client_hello().server_name().map(|s| s.to_string());
return Ok(Some((sni, accepted)));
}
Ok(None) => continue, // 还没凑齐
Err(_) => return Ok(None), // 不是合法 TLS
}
}
}
#[allow(clippy::too_many_arguments)]
async fn handle_tls_connection<S>(
tcp: S,
dst: SocketAddr,
target_hosts: Vec<String>,
target_path: Regex,
save_path: String,
server_config: Arc<rustls::ServerConfig>,
cancel: CancellationToken,
result_tx: Arc<Mutex<Option<tokio::sync::oneshot::Sender<String>>>>,
) -> anyhow::Result<()>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
let cancel_listen = cancel.clone();
tokio::select! {
_ = cancel_listen.cancelled() => Ok(()),
res = async {
// 1. 先嗅探 ClientHello 的 SNI(可重放,不丢字节)
let mut io = RewindStream::new(tcp);
let sniffed = match sniff_client_hello(&mut io).await {
Ok(v) => v,
Err(e) => {
warn!("client hello sniff error on {dst:?}: {e:?}");
return Ok(());
}
};
// 不是 TLS:明文 HTTP / 自定义协议 / 服务器先说话,一律原样透传
let Some((sni, accepted)) = sniffed else {
io.rewind();
io.stop_recording();
return tcp_passthrough(io, dst).await.map(|_| ());
};
if let Some(ref sni_str) = sni
&& DOH_BLACKLIST.iter().any(|d| sni_str.eq_ignore_ascii_case(d)) {
return Ok(());
}
let host_matched = sni
.as_deref()
.map(|s| target_hosts.iter().any(|h| s.eq_ignore_ascii_case(h)))
.unwrap_or(false);
// 不是目标 host,原样透传(必须把已嗅探的 ClientHello 重放给上游)
if !host_matched {
io.rewind();
io.stop_recording();
let r=tcp_passthrough(io, dst).await.map(|_| ());
if r.is_err() {
warn!("tcp_passthrough error on {sni:?} {dst:?}");
}
return r;
}
info!(
"Encountered target host."
);
// 2. 与客户端完成 TLS(accepted 里已经缓存了 ClientHello,不能再重放)
io.stop_recording();
let client_tls = StartHandshake::from_parts(accepted, io)
.into_stream(server_config)
.await?;
// 3. 用 hyper 的 HTTP/1.1 服务端服务这条连接,
// 每个请求单独起一条上游 TLS 连接转发(HKDF 每请求)
let svc_host = sni.clone().unwrap_or_default();
let svc = hyper::service::service_fn(move |req: hyper::Request<Incoming>| {
let host = svc_host.clone();
let path = target_path.clone();
let save = save_path.clone();
let result_tx = result_tx.clone();
let cancel = cancel.clone();
async move {
Ok::<_, std::convert::Infallible>(
forward_request(req, dst, host, path, save, result_tx, cancel).await,
)
}
});
hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(client_tls), svc)
.await?;
Ok(())
} => res
}
}
/// 转发单个请求到真实服务器,命中 target_path 时把响应体落盘。
///
/// 这里用「一个请求一条上游连接」的方式:目标是抓一个 API 响应,
/// 简单、不会踩 keep-alive / 多路复用状态机的坑。
#[allow(clippy::too_many_arguments)]
async fn forward_request(
req: hyper::Request<Incoming>,
dst: SocketAddr,
url_host: String,
target_path: Regex,
save_path: String,
result_tx: Arc<Mutex<Option<tokio::sync::oneshot::Sender<String>>>>,
cancel: CancellationToken,
) -> hyper::Response<Full<Bytes>> {
match forward_request_inner(req, dst, &url_host, &target_path).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!(
"save_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:?}"),
}
cancel.cancel();
}
response
}
Err(e) => {
error!("forward to {dst} failed: {e:?}");
hyper::Response::builder()
.status(hyper::StatusCode::BAD_GATEWAY)
.header("content-type", "text/plain; charset=utf-8")
.body(Full::new(Bytes::from(format!("mitm forward error: {e}"))))
.unwrap()
}
}
}
async fn forward_request_inner(
req: hyper::Request<Incoming>,
dst: SocketAddr,
url_host: &str,
target_path: &Regex,
) -> anyhow::Result<(hyper::Response<Full<Bytes>>, Option<(String, Bytes)>)> {
let (mut parts, body) = req.into_parts();
// 和「模块」的 URL Rewrite 行为一致:把 MySekai 的增量刷新标志强制改成 True,
// 让服务端下发全量数据(游戏自己有时会发 False)。改完再匹配路径 / 转发 / 记 URL。
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();
// 目标正则可能带 query(例如 (?\?isLogin=true)?$),所以要拿 path + query 去匹配
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();
// 去掉逐跳头,避免把下游的 chunked/keep-alive 语义原样塞给上游
for h in [
"connection",
"keep-alive",
"proxy-connection",
"transfer-encoding",
"upgrade",
"te",
"trailer",
"content-length",
] {
parts.headers.remove(h);
}
let mut client_config = rustls::ClientConfig::builder()
.with_root_certificates(root_store())
.with_no_client_auth();
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())?;
let tcp_to_server = tokio::net::TcpStream::connect(dst).await?;
let server_tls = connector.connect(server_name, tcp_to_server).await?;
let (mut sender, conn) =
hyper::client::conn::http1::handshake::<_, Full<Bytes>>(TokioIo::new(server_tls)).await?;
tokio::spawn(async move {
if let Err(e) = conn.await {
warn!("upstream http1 connection error: {e:?}");
}
});
let upstream_req = hyper::Request::from_parts(parts, Full::new(body_bytes));
let resp = sender.send_request(upstream_req).await?;
let (mut res_parts, res_body) = resp.into_parts();
let res_bytes = res_body.collect().await?.to_bytes();
for h in [
"connection",
"keep-alive",
"proxy-connection",
"transfer-encoding",
"upgrade",
"te",
"trailer",
] {
res_parts.headers.remove(h);
}
let is_target = target_path.is_match(&path_and_query);
// 原始完整 URL(https://host + path + ?query)
let url = build_url(url_host, &parts_uri);
let save = if is_target {
Some((url, res_bytes.clone()))
} else {
None
};
Ok((
hyper::Response::from_parts(res_parts, Full::new(res_bytes)),
save,
))
}
/// 拼出 "https://host/path?query" 形式的完整 URL
fn build_url(host: &str, uri: &http::Uri) -> String {
if uri.scheme().is_some() && uri.authority().is_some() {
return uri.to_string();
}
let pq = uri.path_and_query().map(|p| p.as_str()).unwrap_or("/");
format!("https://{host}{pq}")
}
/// `?isForceAllReloadOnlyMysekai=False` -> `=True`
///
/// 游戏在对 MySekai 做增量刷新时会带 False;模块里把它改写成 True 来触发全量下发。
/// 值已经是 True(或没这个参数)时返回 None,不做改动。
fn force_full_mysekai(uri: &http::Uri) -> Option<http::Uri> {
const KEY: &str = "isForceAllReloadOnlyMysekai=";
let pq = uri.path_and_query()?.as_str();
let start = pq.find(KEY)? + KEY.len();
let (head, rest) = pq.split_at(start);
let end = rest.find('&').unwrap_or(rest.len());
let (value, tail) = rest.split_at(end);
if !value.eq_ignore_ascii_case("false") {
return None;
}
http::Uri::try_from(format!("{head}True{tail}").as_str()).ok()
}
pub async fn run_capture_async(
tun_fd: i32,
target_hosts: Vec<String>,
target_path: Regex,
save_path: String,
cancel_token: CancellationToken,
) -> anyhow::Result<Option<String>> {
let device = unsafe { BorrowedAsyncDevice::borrow_raw(tun_fd as RawFd)? };
let mut ipstack_config = ipstack::IpStackConfig::default();
ipstack_config.mtu(1500)?;
let device_ref: &BorrowedAsyncDevice<'static> = unsafe { std::mem::transmute(&device) };
let tun_io = BorrowedTunIo::new(device_ref);
let mut ip_stack = ipstack::IpStack::new(ipstack_config, tun_io);
let mut bridge_tasks = JoinSet::new();
let tls_acceptor = build_tls_acceptor();
let (result_tx, mut result_rx) = tokio::sync::oneshot::channel::<String>();
let result_tx = Arc::new(Mutex::new(Some(result_tx)));
info!("VPN capture started.");
let result: anyhow::Result<Option<String>> = loop {
let token = cancel_token.clone();
tokio::select! {
_ = cancel_token.cancelled() => {
// 取消时如果刚好已经抓到了 URL,也带回去
let final_result = result_rx.try_recv().ok();
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() => {}
res = ip_stack.accept() => {
let stream = match res {
Ok(s) => s,
Err(e) => {
error!("ip_stack accept error (device might be dropped): {e:?}");
break Err(anyhow::Error::from(e));
}
};
match stream {
IpStackStream::Udp(udp) => {
let dst = udp.peer_addr();
if dst.port() == 443 {
// 丢弃 UDP 443,使客户端的 QUIC 快速回退到 TCP
drop(udp);
} else{
bridge_tasks.spawn(async move {
// info!("UDP passthrough: {:?}", dst);
if let Err(e) = udp_passthrough(udp, dst, token).await {
error!("udp passthrough error: {e:?}");
}
});
}
}
IpStackStream::Tcp(mut tcp) => {
let dst = tcp.peer_addr();
if dst.port() == 443 {
// info!("Intercepting TCP 443: {:?}", dst);
//
// 用 duplex 替代手写的 mpsc 通道:duplex 自带正确的
// 反压与 waker 语义(旧实现 try_send 满了直接返回 Pending
// 却不注册 waker,连接会永久卡死,最后被协议栈 RST)。
let (app_io, mut tun_io) = tokio::io::duplex(256 * 1024);
// 在受借用保护的 join_set 启动读写泵
bridge_tasks.spawn(async move {
if let Err(e) =
tokio::io::copy_bidirectional(&mut tun_io, &mut tcp).await
{
// 连接被任意一端关闭时属正常现象
let _ = e;
}
});
// 独立 SPAWN 运行 TLS 业务逻辑(无借用约束)
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();
tokio::spawn(async move {
match handle_tls_connection(
app_io,
dst,
hosts,
path,
save,
server_config,
token,
result_tx
).await {
Ok(()) => { /* 命中时已通过 result_tx 把 URL 送回主循环 */ }
Err(e) => error!("handle tls conn error: {e:?}"),
}
});
}else{
bridge_tasks.spawn(async move {
let _ = tcp_passthrough(tcp, dst).await;
});
}
}
_ => {}
}
}
}
};
// 关键:必须在 runtime 还活着的时候,把所有 bridge task(它们持有 ipstack 的
// IpStackTcpStream)彻底 drop 掉。
// 原因:ipstack 的 Drop 实现会 `block_in_place(...block_on(task_handle))` 等它自己的
// TCP 任务退出。如果拖到 runtime 析构阶段才 drop,worker 已经不跑了,就会永远等下去,
// 表现出来就是「按了停止没反应」。这里主动 abort + join,让 drop 发生在正常调度中。
bridge_tasks.abort_all();
while bridge_tasks.join_next().await.is_some() {}
result
}
async fn tcp_passthrough<IO>(mut client: IO, dst: SocketAddr) -> anyhow::Result<()>
where
IO: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
// info!("TCP passthrough to {dst}");
let mut server = tokio::net::TcpStream::connect(dst).await?;
tokio::io::copy_bidirectional(&mut client, &mut server).await?;
Ok(())
}
async fn udp_passthrough<IO>(
client: IO,
dst: SocketAddr,
cancel: CancellationToken, // 把 cancel 传进来
) -> anyhow::Result<()>
where
IO: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
// 1. 绑定本地 socket
let bind_addr: SocketAddr = if dst.is_ipv4() {
"0.0.0.0:0".parse()?
} else {
"[::]:0".parse()?
};
let socket = UdpSocket::bind(bind_addr).await?;
// 2. Android 关键:一定要 protect,否则流量会重新进 TUN
// 如果你有 VpnService 的 protect 方法,在这里调用
// protect(socket.as_raw_fd());
socket.connect(dst).await?;
let socket = Arc::new(socket);
let (mut cr, mut cw) = tokio::io::split(client);
let mut client_buf = vec![0u8; 65535];
let mut server_buf = vec![0u8; 65535];
// 空闲超时(DNS 建议 8~15 秒,其他 UDP 可以更短)
let idle_timeout = Duration::from_secs(12);
loop {
tokio::select! {
// 外部取消
_ = cancel.cancelled() => break,
// client → server
res = tokio::time::timeout(idle_timeout, cr.read(&mut client_buf)) => {
match res {
Ok(Ok(0)) | Err(_) => break, // EOF 或超时
Ok(Ok(n)) => {
if socket.send(&client_buf[..n]).await.is_err() {
break;
}
}
Ok(Err(_)) => break,
}
}
// server → client
res = tokio::time::timeout(idle_timeout, socket.recv(&mut server_buf)) => {
match res {
Ok(Ok(n)) if n > 0 => {
if cw.write_all(&server_buf[..n]).await.is_err() {
break;
}
}
_ => break, // 超时 / 错误 / 空包
}
}
}
}
Ok(())
}
+205
View File
@@ -0,0 +1,205 @@
use std::io;
use std::ops::Deref;
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
/// 包装你的 Borrowed 设备(无论类型是 BorrowedAsyncDevice 还是对其借用)
pub struct BorrowedTunIo<'a, D> {
device: &'a D,
packet_buf: Vec<u8>,
}
impl<'a, D> BorrowedTunIo<'a, D> {
pub fn new(device: &'a D) -> Self {
Self {
device,
packet_buf: vec![0u8; 65536],
}
}
}
impl<'a, D> AsyncRead for BorrowedTunIo<'a, D>
where
D: DevicePoller,
{
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
let this = &mut *self;
if buf.remaining() == 0 {
return Poll::Ready(Ok(()));
}
// 调用公开方法 poll_recv
match this.device.poll_recv(cx, &mut this.packet_buf) {
Poll::Ready(Ok(n)) => {
let to_copy = std::cmp::min(buf.remaining(), n);
buf.put_slice(&this.packet_buf[..to_copy]);
Poll::Ready(Ok(()))
}
Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
Poll::Pending => Poll::Pending,
}
}
}
impl<'a, D> AsyncWrite for BorrowedTunIo<'a, D>
where
D: DevicePoller,
{
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
// 调用公开方法 poll_send
self.device.poll_send(cx, buf)
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
}
/// 抽象 trait,用于对接你的设备对象
pub trait DevicePoller {
fn poll_recv(&self, cx: &mut Context<'_>, buf: &mut [u8]) -> Poll<io::Result<usize>>;
fn poll_send(&self, cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>>;
}
// 为 tun_rs 提供的结构实现(源码里的 AsyncDevice 实现了对应方法)
impl DevicePoller for tun_rs::AsyncDevice {
fn poll_recv(&self, cx: &mut Context<'_>, buf: &mut [u8]) -> Poll<io::Result<usize>> {
self.poll_recv(cx, buf)
}
fn poll_send(&self, cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
self.poll_send(cx, buf)
}
}
impl<'b> DevicePoller for tun_rs::BorrowedAsyncDevice<'b> {
fn poll_recv(&self, cx: &mut Context<'_>, buf: &mut [u8]) -> Poll<io::Result<usize>> {
self.deref().poll_recv(cx, buf)
}
fn poll_send(&self, cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
self.deref().poll_send(cx, buf)
}
}
/// 可回退(可重放)的流包装器。
///
/// 用于在不破坏字节流的前提下嗅探 TLS ClientHello:
/// * 读过的字节会被记录在 `history` 里;
/// * `rewind()` 之后,下次读取会从 `history` 开头重新吐出来;
/// * `history` 读完之后继续从底层流读取并追加。
///
/// 这样即使最终决定「不是目标,原样透传」,上游依然能收到完整的 ClientHello。
pub struct RewindStream<S> {
inner: S,
history: Vec<u8>,
/// 已经交付给调用方的 `history` 字节数
pos: usize,
/// history 最多记录多少字节
max_record: usize,
recording: bool,
}
impl<S> RewindStream<S> {
pub fn new(inner: S) -> Self {
Self {
inner,
history: Vec::new(),
pos: 0,
max_record: 64 * 1024,
recording: true,
}
}
/// 回到流的起点,让后续读取重放已经嗅探过的字节
pub fn rewind(&mut self) {
self.pos = 0;
}
/// 决策完成后调用:不再缓存新字节(老字节仍会正常重放)。
/// 必须在确定「不会再 rewind」之后调用,否则会丢数据。
pub fn stop_recording(&mut self) {
self.recording = false;
self.history.shrink_to_fit();
}
}
impl<S> AsyncRead for RewindStream<S>
where
S: AsyncRead + Unpin,
{
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
let this = &mut *self;
if this.pos < this.history.len() {
let to_read = std::cmp::min(buf.remaining(), this.history.len() - this.pos);
buf.put_slice(&this.history[this.pos..this.pos + to_read]);
this.pos += to_read;
return Poll::Ready(Ok(()));
}
if !this.recording {
return Pin::new(&mut this.inner).poll_read(cx, buf);
}
// 记录容量用尽:绝不能再从底层读,否则 rewind 会丢字节。
// 直接报错让上层放弃解析(此时的流量本来就不像 TLS)。
let room = this.max_record - this.history.len();
if buf.remaining() > room {
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::InvalidData,
"sniff buffer overflow",
)));
}
let start = buf.filled().len();
match Pin::new(&mut this.inner).poll_read(cx, buf) {
Poll::Ready(Ok(())) => {
let n = buf.filled().len() - start;
if n > 0 {
this.history
.extend_from_slice(&buf.filled()[start..start + n]);
this.pos = this.history.len();
}
Poll::Ready(Ok(()))
}
other => other,
}
}
}
impl<S> AsyncWrite for RewindStream<S>
where
S: AsyncWrite + Unpin,
{
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
Pin::new(&mut self.inner).poll_write(cx, buf)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_shutdown(cx)
}
}
+15
View File
@@ -0,0 +1,15 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
+2
View File
@@ -0,0 +1,2 @@
#Fri Oct 23 15:20:12 CST 2026
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
+10
View File
@@ -0,0 +1,10 @@
## This file is automatically generated by IntelliJ IDEA.
# Do not modify this file -- YOUR CHANGES WILL BE ERASED!
#
# This file should *NOT* be checked into Version Control Systems,
# as it contains information specific to your local configuration.
#
# Location of the SDK. This is only used by Gradle.
# For customization when using a Version Control System, please read the
# header note.
sdk.dir=D\:\\SDK\\Android
+23
View File
@@ -0,0 +1,23 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "IndPjsk"
include(":app")