1.0 能用
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
/.idea
|
||||||
|
/.kotlin
|
||||||
|
/.gradle
|
||||||
|
/build
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/build
|
||||||
|
/release
|
||||||
|
/src/main/jniLibs
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
Vendored
+26
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">咖喱烧烤</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.IndPjsk" parent="android:Theme.Material.Light.NoActionBar"/>
|
||||||
|
</resources>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
/.cargo
|
||||||
|
/.idea
|
||||||
Generated
+1966
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -0,0 +1,2 @@
|
|||||||
|
#Fri Oct 23 15:20:12 CST 2026
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user