8 Commits
Author SHA1 Message Date
Bluemangoo 13e296f106 1.6 2026-11-01 20:18:13 +08:00
Bluemangoo 0f3a225516 请求host不匹配时重新生成证书 2026-11-01 12:39:44 +08:00
Bluemangoo 5b0cf965bd fix 没启用的mixin不再应用 2026-11-01 11:54:55 +08:00
Bluemangoo 544a68bbd4 修自定义配置
null、导入、mixin ignore
2026-10-30 23:40:05 +08:00
Bluemangoo 2582f9ba3e mixin 2026-10-29 16:36:43 +08:00
Bluemangoo 0cf352cabb 重构上传 2026-10-29 14:39:14 +08:00
Bluemangoo 5739f7f8e7 复用连接 2026-10-28 13:51:20 +08:00
Bluemangoo 0851713876 haruki需要替换台服的host 2026-10-26 23:27:46 +08:00
32 changed files with 2943 additions and 717 deletions
+16 -4
View File
@@ -5,14 +5,26 @@ Rootless 开箱即用的 Project Sekai 用户数据抓包工具,支持上传
## 使用
- 安装并打开应用
- 如果需要上传,请在 [Haruki 工具箱](https://haruki.seiunx.com/upload-data) 中的 `iOS 模块` 方式里复制上传码并粘贴到应用中
- 在右上角选择需要的配置
- 可以取消勾选不需要的抓包步骤
- 如果需要上传,在右上角菜单的「上传设置」里添加上传配置。
- 选择需要的配置,可以取消勾选不需要的抓包步骤
- 点击开始,如果跳出建立 VPN 的提示,请允许
- 启动游戏,进入游戏触发需要抓的包,应用会自动抓取数据并自动结束,如果配置了上传会自动上传
- 启动游戏,进入游戏触发需要抓的包,应用会自动抓取数据并自动结束
- 如果需要长时间反复抓同类型的包,在「代理设置」顶部勾选「持续运行」
## 触发位置
- Suite: 登录步骤后,进入游戏的加载阶段(下载资源之前)
- 烤森: 进入烤森
- 烤森生日双叶: 我不知道
## 上游代理
你可能会需要上游代理,有时它是一个运行在本地的应用,此时为了防止冲突,需要将对应的应用加入绕过列表。
在「代理设置」里填写代理地址后,还要勾选「使用上游代理」才会生效;地址为空时无法勾选(等同于直连)。
「持续运行」勾选后抓到包不会自动退出。
## 自定义上传配置
我们提供了三个Header的占位符:`$original_url` `$file_name` `$user_id`
+2 -2
View File
@@ -18,8 +18,8 @@ android {
applicationId = "net.bluemangoo.indpjsk"
minSdk = appMinSdk
targetSdk = 37
versionCode = 1
versionName = "1.1"
versionCode = 7
versionName = "1.6"
}
+5 -4
View File
@@ -1,12 +1,13 @@
<?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" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission"/>
<application
android:name=".App"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
@@ -31,7 +32,7 @@
android:exported="true"
android:permission="android.permission.BIND_VPN_SERVICE" tools:ignore="VpnServicePolicy">
<intent-filter>
<action android:name="android.net.VpnService" />
<action android:name="android.net.VpnService"/>
</intent-filter>
</service>
@@ -42,7 +43,7 @@
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
android:resource="@xml/file_paths"/>
</provider>
</application>
@@ -0,0 +1,16 @@
package net.bluemangoo.indpjsk
import android.app.Application
import android.content.Context
class App : Application() {
companion object {
lateinit var appContext: Context
private set
}
override fun onCreate() {
super.onCreate()
appContext = applicationContext
}
}
@@ -25,10 +25,11 @@ import net.bluemangoo.indpjsk.ui.CaptureScreen
import net.bluemangoo.indpjsk.ui.CaptureViewModel
import net.bluemangoo.indpjsk.ui.CapturedFilesScreen
import net.bluemangoo.indpjsk.ui.ProxySettingsScreen
import net.bluemangoo.indpjsk.ui.UploadSettingsScreen
import net.bluemangoo.indpjsk.ui.theme.IndPjskTheme
import java.io.File
enum class AppScreen { MAIN, PROXY, APP_SELECTION, FILES, ABOUT }
enum class AppScreen { MAIN, PROXY, UPLOAD, APP_SELECTION, FILES, ABOUT }
class MainActivity : ComponentActivity() {
@@ -43,7 +44,7 @@ class MainActivity : ComponentActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
SettingsStore.loadSelectedProfileId(this)
SettingsStore.selectedProfileId
?.let { PredefinedProfiles.byId(it) }
?.let { CaptureStateHolder.selectProfile(it) }
@@ -52,7 +53,7 @@ class MainActivity : ComponentActivity() {
IndPjskTheme {
var screen by rememberSaveable { mutableStateOf(AppScreen.MAIN) }
var bypassPackages by remember {
mutableStateOf(SettingsStore.loadBypassPackages(this))
mutableStateOf(SettingsStore.bypassPackages)
}
val readme = remember {
runCatching {
@@ -73,15 +74,20 @@ class MainActivity : ComponentActivity() {
onStartCapture = { requestVpnAndStart() },
onStopCapture = { viewModel.stopCapture(this) },
onOpenProxySettings = { screen = AppScreen.PROXY },
onOpenUploadSettings = { screen = AppScreen.UPLOAD },
onOpenFiles = { screen = AppScreen.FILES },
onOpenAbout = { screen = AppScreen.ABOUT },
)
AppScreen.UPLOAD -> UploadSettingsScreen(
onBack = { screen = AppScreen.MAIN },
)
AppScreen.PROXY -> ProxySettingsScreen(
bypassPackages = bypassPackages,
onBypassChange = { newSet ->
bypassPackages = newSet
SettingsStore.saveBypassPackages(this, newSet)
SettingsStore.bypassPackages = newSet
},
onSelectApps = { screen = AppScreen.APP_SELECTION },
onBack = { screen = AppScreen.MAIN },
@@ -95,7 +101,7 @@ class MainActivity : ComponentActivity() {
} else {
bypassPackages + entry.packageName
}
SettingsStore.saveBypassPackages(this, bypassPackages)
SettingsStore.bypassPackages = bypassPackages
},
onBack = { screen = AppScreen.PROXY },
)
@@ -11,24 +11,17 @@ data class CaptureProfile(
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 = {},
var postAction: suspend (CaptureResult) -> Unit = {},
val uploadType: String? = null,
val explicitUploadIds: Set<String> = emptySet(),
)
/** 一步抓包完成后的结果,交给 postAction 用 */
data class CaptureResult(
val profile: CaptureProfile,
val step: CaptureProfileStep,
/** 命中的原始完整 URL(https://host/path?query,MySekai 的 flag 已改写为 True) */
val url: String,
/** 抓到的响应体文件 */
val file: File,
)
@@ -1,7 +1,6 @@
package net.bluemangoo.indpjsk.data
import android.content.Context
import android.content.pm.ApplicationInfo
import android.graphics.drawable.Drawable
data class AppEntry(
@@ -16,10 +15,6 @@ object InstalledApps {
val pm = context.packageManager
return pm.getInstalledApplications(0)
.asSequence()
.filter { info ->
(info.flags and ApplicationInfo.FLAG_SYSTEM) == 0 ||
(info.flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) != 0
}
.mapNotNull { info ->
runCatching {
AppEntry(
@@ -0,0 +1,187 @@
package net.bluemangoo.indpjsk.data
import net.bluemangoo.indpjsk.upload.UploadMixin
import net.bluemangoo.indpjsk.upload.UploadProfileCustom
import net.bluemangoo.indpjsk.upload.UploadStore
import net.bluemangoo.indpjsk.upload.Uploader
object MixinProfiles {
fun merged(): List<CaptureProfile> {
val accs = collect()
if (accs.isEmpty()) return PredefinedProfiles.all
val builtIns = PredefinedProfiles.all
val builtInNames = builtIns.map { it.name }.toSet()
val usedProfileIds = builtIns.map { it.id }.toMutableSet()
val mergedBuiltIns = builtIns.map { base ->
val acc = accs[base.name] ?: return@map base
mergeInto(base, acc)
}
val mixinOnly = accs.values
.filter { it.name !in builtInNames }
.mapNotNull { acc ->
toProfile(acc, uniqueKey(usedProfileIds, acc.name, sanitize = true))
}
return mergedBuiltIns + mixinOnly
}
fun validate(mixin: UploadMixin): String? {
for ((name, steps) in mixin.profiles) {
if (steps.isEmpty()) {
return "配置「${name}」没有 steps"
}
for ((name1, hosts, target) in steps) {
if (target !in PredefinedProfiles.stepTargets &&
runCatching { Regex(target) }.isFailure
) {
return "步骤「${name1}」的 target 既不是内置类型,也不是合法的路径正则"
}
if (hosts.isEmpty()) {
return "步骤「${name1}」的 hosts 不能为空"
}
}
}
val upload = mixin.upload
if (upload != null && !upload.ignoreAll) {
for ((region, steps) in upload.ignore) {
if (region.lowercase() !in REGION_IDS) {
return "\"$region\" 不是内置配置"
}
val invalidStep = steps.firstOrNull { it.lowercase() !in PredefinedProfiles.stepTargets }
if (invalidStep != null) {
return "\"$invalidStep\" 不是内置类型"
}
}
}
return null
}
private val REGION_IDS = setOf("cn", "jp", "en", "tw", "kr")
private fun collect(): LinkedHashMap<String, ProfileAcc> {
val profiles = linkedMapOf<String, ProfileAcc>()
for ((enabled, profile) in UploadStore.load()) {
if (!enabled) continue
val custom = profile as? UploadProfileCustom ?: continue
val mixin = custom.mixin ?: continue
for ((name, steps) in mixin.profiles) {
val key = canonicalName(name)
if (key.isBlank()) continue
val profileAcc = profiles.getOrPut(key) { ProfileAcc(key) }
for ((name1, hosts, target1) in steps) {
val target = resolveTarget(target1) ?: continue
val stepKey = name1.trim().lowercase()
if (stepKey.isEmpty()) continue
val stepAcc = profileAcc.steps.getOrPut(stepKey) {
StepAcc(name1.trim(), target)
}
for (host in expandHosts(hosts)) {
if (stepAcc.hosts.none { it.equals(host, ignoreCase = true) }) {
stepAcc.hosts.add(host)
}
}
stepAcc.uploadProfileIds.add(custom.id)
}
}
}
return profiles
}
private fun mergeInto(base: CaptureProfile, acc: ProfileAcc): CaptureProfile {
val builtInStepKeys = base.steps.map { it.name.trim().lowercase() }.toSet()
val usedFileNames = base.steps.map { it.fileName }.toMutableSet()
val usedIds = base.steps.map { it.id }.toMutableSet()
val steps = base.steps.map { step ->
val extra = acc.steps[step.name.trim().lowercase()] ?: return@map step
step.copy(
hosts = (step.hosts + extra.hosts).distinctBy { it.lowercase() },
explicitUploadIds = step.explicitUploadIds + extra.uploadProfileIds,
)
} + acc.steps.values
.filter { it.name.trim().lowercase() !in builtInStepKeys }
.mapNotNull { buildStep(it, usedFileNames, usedIds) }
return base.copy(steps = steps)
}
private fun toProfile(acc: ProfileAcc, id: String): CaptureProfile? {
val usedFileNames = mutableSetOf<String>()
val usedIds = mutableSetOf<String>()
val steps = acc.steps.values.mapNotNull { buildStep(it, usedFileNames, usedIds) }
if (steps.isEmpty()) return null
return CaptureProfile(id = id, name = acc.name, steps = steps)
}
private fun buildStep(
step: StepAcc,
usedFileNames: MutableSet<String>,
usedIds: MutableSet<String>,
): CaptureProfileStep? {
if (step.hosts.isEmpty()) return null
val uploadIds = step.uploadProfileIds.toSet()
return CaptureProfileStep(
id = uniqueKey(usedIds, step.name, sanitize = false),
name = step.name,
hosts = step.hosts.toList(),
path = step.target.path,
fileName = uniqueKey(usedFileNames, step.name, sanitize = true),
postAction = { result -> Uploader.tryUpload(result, uploadIds) },
uploadType = step.target.uploadType,
)
}
private fun resolveTarget(raw: String): PredefinedProfiles.StepTarget? {
PredefinedProfiles.stepTargets[raw]?.let { return it }
if (raw.isBlank()) return null
return runCatching { Regex(raw) }
.map { PredefinedProfiles.StepTarget(path = raw, uploadType = raw) }
.getOrNull()
}
private fun expandHosts(hosts: List<String>): List<String> =
hosts.flatMap { host ->
PredefinedProfiles.hostAliases[host.trim().lowercase()] ?: listOf(host)
}.map { it.trim() }.filter { it.isNotEmpty() }
private fun canonicalName(name: String): String =
PredefinedProfiles.all
.firstOrNull {
it.name.equals(name, ignoreCase = true) || it.id.equals(name, ignoreCase = true)
}
?.name
?: name
private fun uniqueKey(used: MutableSet<String>, base: String, sanitize: Boolean): String {
val clean = if (sanitize) {
base.filter { it.isLetterOrDigit() || it == '_' || it == '-' }.ifEmpty { "step" }
} else {
base.ifBlank { "step" }
}
var candidate = clean
var index = 2
while (!used.add(candidate)) {
candidate = "${clean}_$index"
index++
}
return candidate
}
private class ProfileAcc(val name: String) {
val steps = linkedMapOf<String, StepAcc>()
}
private class StepAcc(
val name: String,
val target: PredefinedProfiles.StepTarget,
) {
val hosts = mutableListOf<String>()
val uploadProfileIds = linkedSetOf<String>()
}
}
@@ -1,26 +1,40 @@
package net.bluemangoo.indpjsk.data
object PredefinedProfiles {
import net.bluemangoo.indpjsk.upload.Uploader
/**
* 两个数据接口的正则(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)?\$"
object PredefinedProfiles {
private const val SUITE_PATH = "/api/suite/user/(\\d+)(\\?isLogin=true)?$"
private const val MYSEKAI_PATH =
"/api/user/(\\d+)/mysekai\\?isForceAllReloadOnlyMysekai=True"
private const val MYSEKAI_BIRTHDAY_PATH =
"/api/user/(\\d+)/mysekai/birthday-party/(\\d+)/delivery"
data class StepTarget(val path: String, val uploadType: String)
val stepTargets: Map<String, StepTarget> = mapOf(
"suite" to StepTarget(SUITE_PATH, "suite"),
"mysekai" to StepTarget(MYSEKAI_PATH, "mysekai"),
"birthday" to StepTarget(MYSEKAI_BIRTHDAY_PATH, "birthday"),
)
val hostAliases: Map<String, List<String>>
get() = mapOf(
"cn" to CN_HOSTS,
"jp" to JP_HOSTS,
"en" to EN_HOSTS,
"tw" to TW_HOSTS,
"kr" to KR_HOSTS
)
private fun suiteStep(hosts: List<String>) = CaptureProfileStep(
id = "suite",
name = "Suite",
hosts = hosts,
path = SUITE_PATH,
fileName = "suite"
fileName = "suite",
postAction = { result ->
Uploader.tryUpload(result)
}
)
private fun mysekaiStep(hosts: List<String>) = CaptureProfileStep(
@@ -28,18 +42,27 @@ object PredefinedProfiles {
name = "烤森",
hosts = hosts,
path = MYSEKAI_PATH,
fileName = "mysekai"
fileName = "mysekai",
postAction = { result ->
Uploader.tryUpload(result)
}
)
private fun mysekaiBirthday(hosts: List<String>) = CaptureProfileStep(
id = "mysekai_birthday",
name = "烤森生日双叶",
hosts = hosts,
path = "/api/user/(\\d+)/mysekai/birthday-party/(\\d+)/delivery",
fileName = "mysekai_birthday"
path = MYSEKAI_BIRTHDAY_PATH,
fileName = "mysekai_birthday",
postAction = { result ->
Uploader.tryUpload(result)
}
)
private val CN_HOSTS = listOf("mkcn-prod-public-60001-1.dailygn.com")
private val CN_HOSTS = listOf(
"mkcn-prod-public-60001-1.dailygn.com",
"mkcn-prod-public-60001-2.dailygn.com"
)
private val JP_HOSTS = listOf("production-game-api.sekai.colorfulpalette.org")
private val EN_HOSTS = listOf("n-production-game-api.sekai-en.com")
private val TW_HOSTS = listOf(
@@ -51,6 +74,15 @@ object PredefinedProfiles {
"mkkorea-obt-prod01-https.bytedgame.com"
)
fun rewriteTwUrl(result: CaptureResult): CaptureResult {
val originalUrl = result.url
val rewrittenUrl = originalUrl.replace(
"mk-zian-obt-https.bytedgame.com",
"mk-zian-obt-cdn.bytedgame.com"
)
return result.copy(url = rewrittenUrl)
}
val all: List<CaptureProfile> = listOf(
// 国服不支持烤森
CaptureProfile(
@@ -71,7 +103,22 @@ object PredefinedProfiles {
CaptureProfile(
id = "tw",
name = "台服",
steps = listOf(suiteStep(TW_HOSTS), mysekaiStep(TW_HOSTS), mysekaiBirthday(TW_HOSTS))
steps = listOf(suiteStep(TW_HOSTS).apply {
postAction = { result ->
val rewrittenResult = rewriteTwUrl(result)
Uploader.tryUpload(rewrittenResult)
}
}, mysekaiStep(TW_HOSTS).apply {
postAction = { result ->
val rewrittenResult = rewriteTwUrl(result)
Uploader.tryUpload(rewrittenResult)
}
}, mysekaiBirthday(TW_HOSTS).apply {
postAction = { result ->
val rewrittenResult = rewriteTwUrl(result)
Uploader.tryUpload(rewrittenResult)
}
})
),
CaptureProfile(
id = "kr",
@@ -80,5 +127,6 @@ object PredefinedProfiles {
),
)
fun byId(id: String): CaptureProfile? = all.firstOrNull { it.id == id }
fun byId(id: String): CaptureProfile? =
MixinProfiles.merged().firstOrNull { it.id == id }
}
@@ -1,49 +1,116 @@
package net.bluemangoo.indpjsk.data
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.edit
import net.bluemangoo.indpjsk.App
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KClass
import kotlin.reflect.KProperty
/** 全局配置持久化:上传码、上游代理、忽略的应用、选中的配置 */
object SettingsStore {
private fun prefs() =
App.appContext.getSharedPreferences("settings", Context.MODE_PRIVATE)
private const val PREFS = "settings"
private const val KEY_UPLOAD_CODE = "upload_code"
private const val KEY_PROXY = "upstream_proxy"
private const val KEY_BYPASS = "bypass_packages"
private const val KEY_PROFILE = "selected_profile"
private inline fun <reified T> pref(
default: T? = null,
key: String? = null
) = PrefProperty(
clazz = T::class,
customDefault = default,
customKey = key,
prefs = { prefs() }
)
private fun prefs(context: Context) =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
var uploadProfilesJson by pref<String>()
var upstreamProxy by pref<String>()
var useProxy by pref<Boolean>()
var bypassPackages by pref<Set<String>>()
var selectedProfileId by pref<String?>(default = null)
var continueRun by pref<Boolean>()
// ---- 上传码 ----
fun loadUploadCode(context: Context): String =
prefs(context).getString(KEY_UPLOAD_CODE, "").orEmpty()
}
fun saveUploadCode(context: Context, code: String) {
prefs(context).edit { putString(KEY_UPLOAD_CODE, code) }
class PrefProperty<T>(
private val clazz: KClass<*>,
private val customDefault: T? = null,
private val customKey: String? = null,
private val prefs: () -> SharedPreferences
) : ReadWriteProperty<Any?, T> {
private var internalState: MutableState<T>? = null
private var resolvedKey: String? = null
private fun getKey(property: KProperty<*>): String =
resolvedKey ?: (customKey ?: property.name).also { resolvedKey = it }
@Suppress("UNCHECKED_CAST")
private fun getDefault(): T {
if (customDefault != null) return customDefault
return when {
clazz == String::class -> "" as T
clazz == Boolean::class -> false as T
clazz == Int::class -> 0 as T
clazz == Long::class -> 0L as T
clazz == Float::class -> 0f as T
clazz == Double::class -> 0.0 as T
Set::class.java.isAssignableFrom(clazz.java) -> emptySet<String>() as T
else -> throw IllegalArgumentException("Unsupported type: ${clazz.simpleName}")
}
}
// ---- 上游代理(空 = 直连) ----
fun loadProxy(context: Context): String =
prefs(context).getString(KEY_PROXY, "").orEmpty()
@Suppress("UNCHECKED_CAST")
private fun getOrCreateState(property: KProperty<*>): MutableState<T> {
return internalState ?: run {
val key = getKey(property)
val sp = prefs()
val fallback = getDefault()
fun saveProxy(context: Context, proxy: String) {
prefs(context).edit { putString(KEY_PROXY, proxy.trim()) }
val initialVal: T = when {
clazz == String::class -> sp.getString(key, fallback as String) as T
clazz == Boolean::class -> sp.getBoolean(key, fallback as Boolean) as T
clazz == Int::class -> sp.getInt(key, fallback as Int) as T
clazz == Long::class -> sp.getLong(key, fallback as Long) as T
clazz == Float::class -> sp.getFloat(key, fallback as Float) as T
Set::class.java.isAssignableFrom(clazz.java) -> {
(sp.getStringSet(key, fallback as? Set<String>)?.toSet() ?: emptySet<String>()) as T
}
// ---- 不走 VPN 的应用(包名) ----
fun loadBypassPackages(context: Context): Set<String> =
prefs(context).getStringSet(KEY_BYPASS, emptySet())?.toSet() ?: emptySet()
fun saveBypassPackages(context: Context, packages: Set<String>) {
prefs(context).edit { putStringSet(KEY_BYPASS, packages) }
else -> fallback
}
mutableStateOf(initialVal).also { internalState = it }
}
}
// ---- 当前选中的配置 ----
fun loadSelectedProfileId(context: Context): String? =
prefs(context).getString(KEY_PROFILE, null)
@Suppress("UNCHECKED_CAST")
private fun saveToSp(value: T) {
val key = checkNotNull(resolvedKey) { "Property has not been initialized yet" }
prefs().edit {
when (value) {
is String -> putString(key, value)
is Boolean -> putBoolean(key, value)
is Int -> putInt(key, value)
is Long -> putLong(key, value)
is Float -> putFloat(key, value)
is Set<*> -> putStringSet(key, HashSet(value as Set<String>))
}
}
}
fun saveSelectedProfileId(context: Context, id: String) {
prefs(context).edit { putString(KEY_PROFILE, id) }
override fun getValue(thisRef: Any?, property: KProperty<*>): T {
return getOrCreateState(property).value
}
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
getOrCreateState(property).value = value
saveToSp(value)
}
operator fun provideDelegate(thisRef: Any?, property: KProperty<*>): PrefProperty<T> {
resolvedKey = customKey ?: property.name
getOrCreateState(property)
return this
}
}
@@ -4,12 +4,6 @@ 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"
@@ -15,14 +15,12 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.bluemangoo.indpjsk.R
import net.bluemangoo.indpjsk.data.CaptureProfile
import net.bluemangoo.indpjsk.data.CaptureProfileStep
import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.data.StepSelectionStore
import net.bluemangoo.indpjsk.state.CaptureStatus
import net.bluemangoo.indpjsk.vpn.LogEntry
@@ -36,6 +34,7 @@ fun CaptureScreen(
onStartCapture: () -> Unit,
onStopCapture: () -> Unit,
onOpenProxySettings: () -> Unit,
onOpenUploadSettings: () -> Unit,
onOpenFiles: () -> Unit,
onOpenAbout: () -> Unit,
) {
@@ -46,7 +45,6 @@ fun CaptureScreen(
var enabledMap by remember { mutableStateOf(StepSelectionStore.loadAll(context) as Map<String, Boolean>) }
var menuExpanded by remember { mutableStateOf(false) }
var profileExpanded by remember { mutableStateOf(false) }
var showUploadCodeDialog by remember { mutableStateOf(false) }
val isRunning = state.status == CaptureStatus.RUNNING
val isCaptured = state.status == CaptureStatus.CAPTURED
@@ -69,10 +67,10 @@ fun CaptureScreen(
onDismissRequest = { menuExpanded = false }
) {
DropdownMenuItem(
text = { Text("上传码") },
text = { Text("上传设置") },
onClick = {
menuExpanded = false
showUploadCodeDialog = true
onOpenUploadSettings()
}
)
DropdownMenuItem(
@@ -143,7 +141,7 @@ fun CaptureScreen(
DropdownMenuItem(
text = { Text(profile.name) },
onClick = {
viewModel.selectProfile(context, profile)
viewModel.selectProfile(profile)
profileExpanded = false
}
)
@@ -215,48 +213,6 @@ fun CaptureScreen(
}
}
}
if (showUploadCodeDialog) {
UploadCodeDialog(onDismiss = { showUploadCodeDialog = false })
}
}
@Composable
private fun UploadCodeDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
var text by remember { mutableStateOf(SettingsStore.loadUploadCode(context)) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("上传码") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = text,
onValueChange = { text = it.trim() },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Haruki 上传码") },
visualTransformation = PasswordVisualTransformation(),
textStyle = MaterialTheme.typography.bodyMedium
)
Text(
text = "从 Haruki 工具箱 - 数据上传 - iOS 获取。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
confirmButton = {
TextButton(onClick = {
SettingsStore.saveUploadCode(context, text)
onDismiss()
}) { Text("保存") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("取消") }
}
)
}
@Composable
@@ -5,15 +5,17 @@ import android.content.Intent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import net.bluemangoo.indpjsk.data.CaptureProfile
import net.bluemangoo.indpjsk.data.MixinProfiles
import net.bluemangoo.indpjsk.data.PredefinedProfiles
import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.state.CaptureStateHolder
import net.bluemangoo.indpjsk.state.CaptureStatus
import net.bluemangoo.indpjsk.upload.UploadStore
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.combine
import kotlinx.coroutines.flow.stateIn
import java.io.File
@@ -27,28 +29,32 @@ data class CaptureUiState(
class CaptureViewModel : ViewModel() {
val uiState: StateFlow<CaptureUiState> = CaptureStateHolder.state
.map { s ->
private fun allProfiles(): List<CaptureProfile> = MixinProfiles.merged()
val uiState: StateFlow<CaptureUiState> = combine(
CaptureStateHolder.state,
UploadStore.revision,
) { s, _ ->
val profiles = allProfiles()
CaptureUiState(
profiles = PredefinedProfiles.all,
selectedProfile = s.profile,
profiles = profiles,
selectedProfile = profiles.firstOrNull { it.id == s.profile.id } ?: s.profile,
status = s.status,
lastCapturedFile = s.lastCapturedFile,
errorMessage = s.errorMessage
)
}
.stateIn(
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = CaptureUiState(
profiles = PredefinedProfiles.all,
profiles = allProfiles(),
selectedProfile = PredefinedProfiles.all.firstOrNull()
)
)
fun selectProfile(context: Context, profile: CaptureProfile) {
fun selectProfile(profile: CaptureProfile) {
CaptureStateHolder.selectProfile(profile)
SettingsStore.saveSelectedProfileId(context, profile.id)
SettingsStore.selectedProfileId = profile.id
}
fun startCapture(context: Context) {
@@ -18,6 +18,7 @@ import net.bluemangoo.indpjsk.R
import net.bluemangoo.indpjsk.data.AppEntry
import net.bluemangoo.indpjsk.data.InstalledApps
import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.util.asState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -28,7 +29,9 @@ fun ProxySettingsScreen(
onBack: () -> Unit,
) {
val context = LocalContext.current
var proxy by remember { mutableStateOf(SettingsStore.loadProxy(context)) }
var proxy by SettingsStore::upstreamProxy.asState()
var useProxy by SettingsStore::useProxy.asState()
var continueRun by SettingsStore::continueRun.asState()
var pendingRemove by remember { mutableStateOf<AppEntry?>(null) }
val entries = remember(bypassPackages) {
@@ -37,6 +40,7 @@ fun ProxySettingsScreen(
val looksValid = proxy.isBlank() ||
((proxy.startsWith("http://") || proxy.startsWith("https://")) && proxy.contains(":"))
val canUseProxy = proxy.isNotBlank() && looksValid
Scaffold(
topBar = {
@@ -60,17 +64,61 @@ fun ProxySettingsScreen(
.padding(horizontal = 0.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = continueRun,
onCheckedChange = { continueRun = it }
)
Text(
text = "持续运行",
style = MaterialTheme.typography.bodyMedium
)
}
Text(
text = "抓到包后不自动停止,继续接受同类型的包",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, bottom = 4.dp)
)
}
HorizontalDivider()
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("上游代理", style = MaterialTheme.typography.labelLarge)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = useProxy && canUseProxy,
onCheckedChange = {
useProxy = it
},
enabled = canUseProxy
)
Text(
text = "使用上游代理",
style = MaterialTheme.typography.bodyMedium
)
}
OutlinedTextField(
value = proxy,
onValueChange = {
proxy = it
SettingsStore.saveProxy(context, it)
proxy = it.trim()
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
@@ -79,10 +127,9 @@ fun ProxySettingsScreen(
isError = !looksValid,
supportingText = {
Text(
text = if (looksValid) {
"留空以忽略;只支持 http / https 代理"
} else {
"格式示例:http://127.0.0.1:7890"
text = when {
!looksValid -> "格式示例:http://127.0.0.1:7890"
else -> "只支持 http / https 代理"
}
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,305 @@
package net.bluemangoo.indpjsk.upload
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.vpn.VpnLog
import org.json.JSONArray
import org.json.JSONObject
data class UploadEntry(
val enabled: Boolean,
val profile: UploadProfile,
)
object UploadStore {
private const val VERSION = 1
private val _revision = MutableStateFlow(0)
val revision: StateFlow<Int> = _revision.asStateFlow()
private const val KEY_VERSION = "version"
private const val KEY_PROFILES = "profiles"
private const val KEY_ENABLED = "enabled"
private const val KEY_PROFILE = "profile"
private const val KEY_ID = "id"
private const val KEY_TYPE = "type"
private const val KEY_UPLOAD_CODE = "uploadCode"
private const val KEY_UPLOAD_URL = "uploadUrl"
private const val KEY_MODE = "mode"
private const val KEY_URL = "url"
private const val KEY_HEADERS = "headers"
private const val KEY_MIXIN = "mixin"
private const val KEY_NAME = "name"
private const val KEY_STEPS = "steps"
private const val KEY_HOSTS = "hosts"
private const val KEY_TARGET = "target"
private const val KEY_UPLOAD = "upload"
private const val KEY_IGNORE = "ignore"
private const val KEY_SUITE = "suite"
private const val KEY_MYSEKAI = "mysekai"
private const val KEY_BIRTHDAY = "birthday"
const val URL_MODE_GENERAL = "general"
const val URL_MODE_EACH = "each"
fun load(): List<UploadEntry> {
val raw = SettingsStore.uploadProfilesJson
return runCatching { decode(raw) }
.onFailure { VpnLog.error("上传配置读取失败: ${it.message}") }
.getOrDefault(emptyList())
}
fun save(entries: List<UploadEntry>) {
SettingsStore.uploadProfilesJson = encode(entries)
_revision.value += 1
}
fun encode(entries: List<UploadEntry>): String {
val profiles = JSONArray()
entries.forEach { entry ->
profiles.put(JSONObject().apply {
put(KEY_ENABLED, entry.enabled)
put(KEY_PROFILE, encodeProfile(entry.profile))
})
}
return JSONObject().apply {
put(KEY_VERSION, VERSION)
put(KEY_PROFILES, profiles)
}.toString()
}
fun decode(raw: String): List<UploadEntry> {
val root = JSONObject(raw)
val arr = root.optJSONArray(KEY_PROFILES) ?: JSONArray()
val out = mutableListOf<UploadEntry>()
for (i in 0 until arr.length()) {
val item = arr.optJSONObject(i) ?: continue
val obj = item.optJSONObject(KEY_PROFILE) ?: continue
val profile = decodeProfile(obj)
if (profile == null) {
VpnLog.warn("跳过无法识别的上传配置: ${obj.textOrNull(KEY_TYPE).orEmpty()}")
continue
}
out += UploadEntry(item.optBoolean(KEY_ENABLED, false), profile)
}
return out
}
fun encodeProfile(profile: UploadProfile): JSONObject = JSONObject().apply {
put(KEY_TYPE, profile.type.name)
put(KEY_ID, profile.id)
when (profile) {
is UploadProfileHaruki -> put(KEY_UPLOAD_CODE, profile.uploadCode)
is UploadProfileReasona -> put(KEY_UPLOAD_CODE, profile.uploadCode)
is UploadProfile8823 -> Unit
is UploadProfileCustom -> {
put(KEY_UPLOAD_URL, encodeUrl(profile.uploadUrl))
put(KEY_HEADERS, JSONObject(profile.customHeaders as Map<*, *>))
profile.mixin?.let { put(KEY_MIXIN, encodeMixin(it)) }
}
}
}
fun decodeProfile(obj: JSONObject): UploadProfile? {
val id = obj.textOrNull(KEY_ID).orEmpty()
val typeName = obj.textOrNull(KEY_TYPE).orEmpty()
val type = UploadProfileType.entries.firstOrNull { it.name == typeName }
?: return null
val uploadCode = obj.textOrNull(KEY_UPLOAD_CODE).orEmpty()
return when (type) {
UploadProfileType.HARUKI -> UploadProfileHaruki(id, uploadCode)
UploadProfileType.REASONA -> UploadProfileReasona(id, uploadCode)
UploadProfileType._8823 -> UploadProfile8823(id)
UploadProfileType.CUSTOM -> UploadProfileCustom(
id = id,
uploadUrl = decodeUrl(obj.optJSONObject(KEY_UPLOAD_URL)),
customHeaders = decodeHeaders(obj.optJSONObject(KEY_HEADERS)),
mixin = decodeMixin(obj.optJSONObject(KEY_MIXIN)),
)
}
}
fun parseMixin(text: String): UploadMixin? {
if (text.isBlank()) return null
return decodeMixin(JSONObject(text))
}
fun encodeMixin(mixin: UploadMixin): JSONObject = JSONObject().apply {
if (mixin.profiles.isNotEmpty()) {
put(KEY_PROFILES, JSONArray().apply {
mixin.profiles.forEach { profile ->
put(JSONObject().apply {
put(KEY_NAME, profile.name)
put(KEY_STEPS, JSONArray().apply {
profile.steps.forEach { step ->
put(JSONObject().apply {
put(KEY_NAME, step.name)
put(KEY_HOSTS, JSONArray(step.hosts))
put(KEY_TARGET, step.target)
})
}
})
})
}
})
}
mixin.upload?.let { put(KEY_UPLOAD, encodeUpload(it)) }
}
fun decodeMixin(obj: JSONObject?): UploadMixin? {
if (obj == null) return null
val profiles = mutableListOf<UploadMixinProfile>()
val profileArray = obj.optJSONArray(KEY_PROFILES)
if (profileArray != null) {
for (i in 0 until profileArray.length()) {
val profileObj = profileArray.optJSONObject(i) ?: continue
val name = profileObj.textOrNull(KEY_NAME).orEmpty().trim()
if (name.isEmpty()) continue
val steps = mutableListOf<UploadMixinStep>()
val stepArray = profileObj.optJSONArray(KEY_STEPS) ?: JSONArray()
for (j in 0 until stepArray.length()) {
val stepObj = stepArray.optJSONObject(j) ?: continue
val stepName = stepObj.textOrNull(KEY_NAME).orEmpty().trim()
if (stepName.isEmpty()) continue
val hosts = mutableListOf<String>()
val hostArray = stepObj.optJSONArray(KEY_HOSTS) ?: JSONArray()
for (k in 0 until hostArray.length()) {
val host = hostArray.textOrNull(k).orEmpty().trim()
if (host.isNotEmpty()) hosts.add(host)
}
steps.add(
UploadMixinStep(
name = stepName,
hosts = hosts,
target = stepObj.textOrNull(KEY_TARGET).orEmpty().trim(),
)
)
}
profiles.add(UploadMixinProfile(name, steps))
}
}
val upload = decodeUpload(obj.optJSONObject(KEY_UPLOAD))
if (profiles.isEmpty() && upload == null) return null
return UploadMixin(profiles, upload)
}
private fun encodeUpload(upload: UploadMixinUpload): JSONObject = JSONObject().apply {
when {
upload.ignoreAll -> put(KEY_IGNORE, "*")
upload.ignore.isNotEmpty() -> put(KEY_IGNORE, JSONArray().apply {
upload.ignore.forEach { item ->
if (item.steps.isEmpty()) {
put(item.region)
} else {
put(JSONObject().apply {
put(KEY_NAME, item.region)
put(KEY_STEPS, JSONArray(item.steps))
})
}
}
})
}
}
private fun decodeUpload(obj: JSONObject?): UploadMixinUpload? {
if (obj == null) return null
@Suppress("UNEXHAUSTIVE_WHEN_BASED_ON_JAVA_ANNOTATIONS")
return when (val raw = obj.opt(KEY_IGNORE)) {
is String -> when {
raw == "*" -> UploadMixinUpload(ignoreAll = true)
raw.isBlank() -> null
else -> UploadMixinUpload(ignore = listOf(UploadIgnoreProfile(raw.trim())))
}
is JSONArray -> {
val items = mutableListOf<UploadIgnoreProfile>()
for (i in 0 until raw.length()) {
when (val item = raw.opt(i)) {
is String -> {
if (item.isNotBlank()) {
items.add(UploadIgnoreProfile(item.trim()))
}
}
is JSONObject -> {
val region = item.textOrNull(KEY_NAME).orEmpty().trim()
if (region.isEmpty()) continue
val steps = mutableListOf<String>()
val stepArray = item.optJSONArray(KEY_STEPS) ?: JSONArray()
for (j in 0 until stepArray.length()) {
val step = stepArray.textOrNull(j).orEmpty().trim()
if (step.isNotEmpty()) steps.add(step)
}
items.add(UploadIgnoreProfile(region, steps))
}
}
}
UploadMixinUpload(ignore = items)
}
else -> null
}
}
private fun encodeUrl(url: UploadUrl): JSONObject = when (url) {
is UploadUrlGeneral -> JSONObject().apply {
put(KEY_MODE, URL_MODE_GENERAL)
put(KEY_URL, url.url)
}
is UploadUrlEach -> JSONObject().apply {
put(KEY_MODE, URL_MODE_EACH)
put(KEY_SUITE, url.suite ?: JSONObject.NULL)
put(KEY_MYSEKAI, url.mysekai ?: JSONObject.NULL)
put(KEY_BIRTHDAY, url.birthday ?: JSONObject.NULL)
}
}
private fun decodeUrl(obj: JSONObject?): UploadUrl {
if (obj == null) return UploadUrlGeneral("")
val mode = obj.textOrNull(KEY_MODE)?.trim()?.takeIf { it.isNotEmpty() } ?: URL_MODE_GENERAL
return when (mode) {
URL_MODE_EACH -> UploadUrlEach(
suite = obj.optUrlOrNull(KEY_SUITE),
mysekai = obj.optUrlOrNull(KEY_MYSEKAI),
birthday = obj.optUrlOrNull(KEY_BIRTHDAY),
)
else -> UploadUrlGeneral(
obj.textOrNull(KEY_URL)?.trim()?.takeIf {
it.isNotEmpty() && !it.equals("null", ignoreCase = true)
} ?: ""
)
}
}
private fun JSONObject.optUrlOrNull(key: String): String? =
textOrNull(key)?.trim()?.takeIf { it.isNotEmpty() && !it.equals("null", ignoreCase = true) }
private fun decodeHeaders(obj: JSONObject?): Map<String, String> {
if (obj == null) return emptyMap()
val map = linkedMapOf<String, String>()
val keys = obj.keys()
while (keys.hasNext()) {
val key = keys.next()
map[key] = obj.textOrNull(key) ?: continue
}
return map
}
private fun JSONObject.textOrNull(key: String): String? =
if (isNull(key)) null else optString(key)
private fun JSONArray.textOrNull(index: Int): String? =
if (isNull(index)) null else optString(index)
}
@@ -1,84 +1,118 @@
package net.bluemangoo.indpjsk.upload
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.bluemangoo.indpjsk.data.CaptureResult
import net.bluemangoo.indpjsk.vpn.VpnLog
import org.json.JSONObject
import java.io.File
import java.io.IOException
import java.io.RandomAccessFile
import java.net.HttpURLConnection
import java.net.URL
import java.util.*
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,
suspend fun tryUpload(result: CaptureResult, onlyProfileIds: Set<String>? = null) {
val entries = UploadStore.load()
.filter { it.enabled }
.filter { onlyProfileIds == null || it.profile.id in onlyProfileIds }
.filter { entry ->
val definedHere =
entry.profile.id in result.step.explicitUploadIds ||
(onlyProfileIds != null && entry.profile.id in onlyProfileIds)
if (definedHere) {
true
} else {
val upload = (entry.profile as? UploadProfileCustom)?.mixin?.upload
upload == null || !upload.ignores(result.profile.id, result.step.uploadType)
}
}
if (entries.isEmpty()) return
VpnLog.info("${result.step.name} 开始上传(${entries.size} 个配置)")
for ((_, profile) in entries) {
try {
val uploaded = uploadChunked(profile, result.url, result.file, result.step.uploadType)
if (uploaded) {
VpnLog.success("[${profile.id}] ${result.step.name} 上传完成")
}
} catch (c: CancellationException) {
throw c
} catch (t: Throwable) {
VpnLog.error("[${profile.id}] ${result.step.name} 上传失败: ${t.message}")
}
}
}
suspend fun uploadChunked(
profile: UploadProfile,
originalUrl: String,
file: File,
) = withContext(Dispatchers.IO) {
type: String? = null,
): Boolean = withContext(Dispatchers.IO) {
require(file.exists()) { "文件不存在: ${file.absolutePath}" }
val uploadType = type ?: when (file.name) {
"suite" -> "suite"
"mysekai" -> "mysekai"
"mysekai_birthday" -> "birthday"
else -> null
}
if (uploadType == null) {
VpnLog.error("${profile.id} 未知的文件类型 ${file.name}")
return@withContext false
}
val url = profile.getUploadUrl(uploadType)?.takeIf { it.isNotBlank() }
?: return@withContext false
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} 上传开始")
VpnLog.info("[${profile.id}] ${file.name} 上传开始 (${size} bytes)")
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)
val code = postChunk(profile, url, uploadId, index, total, originalUrl, buf, file, len)
if (code != 200) throw IOException("${file.name} 上传失败: HTTP $code")
}
VpnLog.info("${file.name} 上传完成")
}
true
}
private fun postChunk(
uploadCode: String,
profile: UploadProfile,
url: String,
uploadId: String,
index: Int,
total: Int,
originalUrl: String,
data: ByteArray,
file: File,
length: Int,
): Int {
val conn = URL("$BASE/$uploadCode/upload").openConnection() as HttpURLConnection
val conn = URL(url).openConnection() as HttpURLConnection
try {
conn.requestMethod = "POST"
conn.doOutput = true
conn.instanceFollowRedirects = false
conn.instanceFollowRedirects = true
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)
profile.preRequest(conn, originalUrl, file)
conn.outputStream.use { it.write(data, 0, length) }
val code = conn.responseCode
@@ -89,7 +123,7 @@ object Uploader {
?.use { it.readText() }
?.take(300)
}.getOrNull().orEmpty()
throw IOException("分片 ${index + 1}/$total 上传失败: HTTP $code $body")
throw IOException("${profile.id} 分片 ${index + 1}/$total 上传失败: HTTP $code $body")
}
return code
} finally {
@@ -97,7 +131,6 @@ object Uploader {
}
}
/** 9 位随机字母数字,同一文件所有分片共用一个 */
private fun randomUploadId(): String {
val chars = "0123456789abcdefghijklmnopqrstuvwxyz"
return buildString(9) {
@@ -105,3 +138,234 @@ object Uploader {
}
}
}
sealed interface UploadUrl {
fun getUrl(type: String): String?
}
data class UploadUrlGeneral(
val url: String
) : UploadUrl {
override fun getUrl(type: String): String {
return url
}
}
data class UploadUrlEach(
val suite: String?,
val mysekai: String?,
val birthday: String?,
) : UploadUrl {
override fun getUrl(type: String): String? {
return when (type) {
"suite" -> suite
"mysekai" -> mysekai
"birthday" -> birthday
else -> null
}
}
}
enum class UploadProfileType(val displayName: String) {
HARUKI("Haruki"),
REASONA("Reasona"),
_8823("8823"),
CUSTOM("自定义")
}
/** mixin:在自定义上传配置里顺带定义抓包配置。 */
data class UploadMixin(
val profiles: List<UploadMixinProfile> = emptyList(),
val upload: UploadMixinUpload? = null,
)
data class UploadMixinProfile(
val name: String,
val steps: List<UploadMixinStep>,
)
/**
* name: 步骤显示名(同一 profile 内同名步骤会合并)
* hosts: 域名列表,可以填 `cn` / `jp` / `en` / `tw` / `kr` 这样的预设别名
* target: `suite` / `mysekai` / `birthday` 快捷方式,或直接填请求路径正则
*/
data class UploadMixinStep(
val name: String,
val hosts: List<String>,
val target: String,
)
/**
* `upload.ignore`:这个上传配置对哪些"不是自己 mixin 定义"的步骤不生效。
* 自己 mixin 定义出来的步骤永远会上传(见 [ignores] 的调用处)。
*/
data class UploadMixinUpload(
/** ignore: "*" */
val ignoreAll: Boolean = false,
val ignore: List<UploadIgnoreProfile> = emptyList(),
) {
fun ignores(region: String, stepType: String?): Boolean {
if (ignoreAll) return true
return ignore.any { item ->
item.region.equals(region, ignoreCase = true) &&
(item.steps.isEmpty() ||
(stepType != null && item.steps.any { it.equals(stepType, ignoreCase = true) }))
}
}
}
data class UploadIgnoreProfile(
/** Region:cn / jp / en / tw / kr */
val region: String,
/** 空表示整个配置 */
val steps: List<String> = emptyList(),
)
sealed interface UploadProfile {
val id: String
val type: UploadProfileType
val helpText: String?
fun getUploadUrl(type: String): String?
fun preRequest(
conn: HttpURLConnection, originalUrl: String, file: File,
)
val extraInfo: Map<String, String>
}
class UploadProfileHaruki(override val id: String, val uploadCode: String) : UploadProfile {
companion object {
private const val API_BASE = "https://toolbox-api-direct.haruki.seiunx.com/ios/script"
}
override val extraInfo: Map<String, String>
get() = emptyMap()
override val helpText: String =
"在 [Haruki 工具箱](https://haruki.seiunx.com/upload-data) 的「iOS模块」里复制上传码。"
override val type: UploadProfileType = UploadProfileType.HARUKI
override fun getUploadUrl(type: String): String {
return "$API_BASE/$uploadCode/upload"
}
override fun preRequest(conn: HttpURLConnection, originalUrl: String, file: File) {
conn.setRequestProperty("X-Script-Version", "1.0.1")
conn.setRequestProperty("X-Upload-Code", uploadCode)
}
}
class UploadProfileReasona(override val id: String, val uploadCode: String) : UploadProfile {
companion object {
private const val API_BASE = "https://api.resona.cn/scripts/"
}
override val type: UploadProfileType = UploadProfileType.REASONA
override val helpText: String =
"在 [Reasona 工具箱](https://web.resona.cn/tools/upload) 的 Haruki Proxy 里复制 token"
private val _uploadUrl = UploadUrlEach(
suite = "$API_BASE/suite/upload?jwt=$uploadCode",
mysekai = "$API_BASE/mysekai/upload?jwt=$uploadCode",
birthday = "$API_BASE/mysekai_birthday_party/upload?jwt=$uploadCode"
)
override fun getUploadUrl(type: String): String? {
return _uploadUrl.getUrl(type)
}
override fun preRequest(conn: HttpURLConnection, originalUrl: String, file: File) {
conn.setRequestProperty("X-Script-Version", "0.4.2")
val userId = originalUrl.tryParseUserId()
if (userId != null) {
conn.setRequestProperty("X-User-Id", userId)
}
}
private fun parseJwt(): JSONObject? {
val parts = uploadCode.split(".")
if (parts.size < 2) return null
val payload =
try {
val payloadBytes = Base64.getUrlDecoder().decode(parts[1])
String(payloadBytes, Charsets.UTF_8)
} catch (_: IllegalArgumentException) {
return null
}
return runCatching { JSONObject(payload) }.getOrNull()
}
override val extraInfo: Map<String, String>
get() {
val payload = parseJwt() ?: return emptyMap()
val map = mutableMapOf<String, String>()
if (payload.has("qqnum") && !payload.isNull("qqnum")) {
map["QQ 号"] = payload.optString("qqnum")
}
if (payload.has("sync_8823") && !payload.isNull("sync_8823")) {
map["同步给 8823"] = payload.optBoolean("sync_8823").toString()
}
return map
}
}
class UploadProfile8823(override val id: String) : UploadProfile {
override val type: UploadProfileType = UploadProfileType._8823
override val helpText: String? = null
private val _uploadUrl = UploadUrlGeneral("https://api-8823.resona.cn/api/public/V1/upload")
override fun getUploadUrl(type: String): String {
return _uploadUrl.getUrl(type)
}
override fun preRequest(conn: HttpURLConnection, originalUrl: String, file: File) {
conn.setRequestProperty("X-Script-Version", "0.3.0")
val userId = originalUrl.tryParseUserId()
if (userId != null) {
conn.setRequestProperty("X-User-Id", userId)
}
}
override val extraInfo: Map<String, String> = emptyMap()
}
class UploadProfileCustom(
override val id: String,
val uploadUrl: UploadUrl,
val customHeaders: Map<String, String>,
val mixin: UploadMixin? = null,
) : UploadProfile {
override val type: UploadProfileType = UploadProfileType.CUSTOM
override val helpText: String? = null
override fun getUploadUrl(type: String): String? {
return uploadUrl.getUrl(type)
}
override fun preRequest(conn: HttpURLConnection, originalUrl: String, file: File) {
customHeaders.forEach { (key, value) ->
when (value) {
$$"$original_url" -> conn.setRequestProperty(key, originalUrl)
$$"$file_name" -> conn.setRequestProperty(key, file.name)
$$"$user_id" -> originalUrl.tryParseUserId()?.let { conn.setRequestProperty(key, it) }
else -> conn.setRequestProperty(key, value)
}
}
}
override val extraInfo: Map<String, String> = emptyMap()
}
private fun String.tryParseUserId(): String? {
val regex = Regex("""/user/(\d+)|suite/user/(\d+)""")
val match = regex.find(this) ?: return null
return match.groupValues[1].ifEmpty { match.groupValues[2] }
.ifEmpty { null }
}
@@ -0,0 +1,20 @@
package net.bluemangoo.indpjsk.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.remember
import kotlin.reflect.KMutableProperty0
@Composable
fun <T> KMutableProperty0<T>.asState(): MutableState<T> {
return remember {
object : MutableState<T> {
override var value: T
get() = this@asState.get()
set(v) { this@asState.set(v) }
override fun component1(): T = value
override fun component2(): (T) -> Unit = { value = it }
}
}
}
@@ -0,0 +1 @@
package net.bluemangoo.indpjsk.util
@@ -4,20 +4,14 @@ 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 kotlinx.coroutines.*
import net.bluemangoo.indpjsk.data.*
import net.bluemangoo.indpjsk.state.CaptureStateHolder
import net.bluemangoo.indpjsk.upload.Uploader
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
@SuppressLint("VpnServicePolicy")
class CaptureVpnService : VpnService() {
@@ -33,9 +27,7 @@ class CaptureVpnService : VpnService() {
private val postScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun captureFile(
filesDir: File,
profile: CaptureProfile,
step: CaptureProfileStep
filesDir: File, profile: CaptureProfile, step: CaptureProfileStep
): File = File(File(File(filesDir, "captures"), profile.id), step.fileName)
}
@@ -48,6 +40,16 @@ class CaptureVpnService : VpnService() {
@Volatile
private var stopRequested = false
private val stepResults = ConcurrentHashMap<String, String>()
private val finishedSteps = ConcurrentHashMap.newKeySet<String>()
@Volatile
private var allDone: CountDownLatch? = null
@Volatile
private var lastCaptured: File? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
@@ -59,12 +61,14 @@ class CaptureVpnService : VpnService() {
ACTION_STOP -> {
stopRequested = true
VpnLog.warn("手动停止…")
VpnBridge.requestStop()
closeActivePfd()
VpnBridge.stopSession()
allDone?.countDown()
CaptureStateHolder.setIdle()
if (captureThread?.isAlive != true) {
closeActivePfd()
stopSelf()
}
}
else -> return START_NOT_STICKY
}
@@ -76,7 +80,6 @@ class CaptureVpnService : VpnService() {
activePfd = null
try {
pfd?.close()
VpnBridge.requestStop()
} catch (t: Throwable) {
VpnLog.warn("关闭代理失败: ${t.message}")
}
@@ -98,27 +101,50 @@ class CaptureVpnService : VpnService() {
return
}
val uploadCode = SettingsStore.loadUploadCode(this).trim()
if (uploadCode.isNotEmpty() && uploadCode.length != 32) {
VpnLog.warn("上传码长度不是 32 位(当前 ${uploadCode.length} 位),上传大概率会失败")
val proxy: String? = if (SettingsStore.useProxy) {
SettingsStore.upstreamProxy.trim().takeIf { it.isNotEmpty() }
} else {
null
}
val bypassPackages = SettingsStore.bypassPackages
if (proxy != null) {
VpnLog.info("上游代理: $proxy")
} else {
VpnLog.info("未启用上游代理")
}
val proxy = SettingsStore.loadProxy(this).trim()
val bypassPackages = SettingsStore.loadBypassPackages(this)
if (proxy.isNotEmpty()) VpnLog.info("上游代理: $proxy")
if (bypassPackages.isNotEmpty()) {
VpnLog.info("忽略的应用: ${bypassPackages.joinToString(", ")}")
}
stopRequested = false
stepResults.clear()
finishedSteps.clear()
lastCaptured = null
val latch = CountDownLatch(1)
allDone = latch
val continueRun = SettingsStore.continueRun
VpnLog.info("==== 开始抓包 ====")
VpnLog.info("配置: ${profile.name}")
VpnLog.info("步骤: ${enabledSteps.joinToString(" -> ") { it.name }}")
VpnLog.info("启用: ${enabledSteps.joinToString(" / ") { it.name }}")
if (continueRun) VpnLog.info("持续运行: 开启(抓到后不会自动停止)")
VpnBridge.stepListener = { stepId, url ->
val step = enabledSteps.firstOrNull { it.id == stepId }
if (step == null) {
VpnLog.warn("收到未知步骤的结果: $stepId")
} else {
handleStepResult(profile, step, url)
finishedSteps.add(step.id)
if (!continueRun && finishedSteps.size >= enabledSteps.size) {
latch.countDown()
}
}
}
captureThread = Thread({
var pfd: ParcelFileDescriptor?
var lastFile: File? = null
var completed = 0
var errorMsg: String? = null
try {
@@ -126,164 +152,133 @@ class CaptureVpnService : VpnService() {
throw IllegalStateException("Rust 动态库 core_vpn 未加载")
}
val builder = Builder()
.setSession("Capture-${profile.id}")
.addAddress(VPN_ADDRESS, VPN_PREFIX)
.addDisallowedApplication(packageName)
.addRoute("1.0.0.0", 8)
val builder = Builder().setSession("Capture-${profile.id}").addAddress(VPN_ADDRESS, VPN_PREFIX)
.addDisallowedApplication(packageName).addRoute("1.0.0.0", 8)
.addRoute("2.0.0.0", 7)
.addRoute("4.0.0.0", 6)
.addRoute("8.0.0.0", 7)
.addRoute("2.0.0.0", 7).addRoute("4.0.0.0", 6).addRoute("8.0.0.0", 7)
.addRoute("11.0.0.0", 8)
.addRoute("12.0.0.0", 6)
.addRoute("16.0.0.0", 4)
.addRoute("32.0.0.0", 3)
.addRoute("64.0.0.0", 5)
.addRoute("96.0.0.0", 10)
.addRoute("11.0.0.0", 8).addRoute("12.0.0.0", 6).addRoute("16.0.0.0", 4).addRoute("32.0.0.0", 3)
.addRoute("64.0.0.0", 5).addRoute("96.0.0.0", 10)
.addRoute("100.128.0.0", 9)
.addRoute("101.0.0.0", 8)
.addRoute("102.0.0.0", 7)
.addRoute("104.0.0.0", 5)
.addRoute("112.0.0.0", 4)
.addRoute("100.128.0.0", 9).addRoute("101.0.0.0", 8).addRoute("102.0.0.0", 7)
.addRoute("104.0.0.0", 5).addRoute("112.0.0.0", 4)
.addRoute("128.0.0.0", 3)
.addRoute("160.0.0.0", 5)
.addRoute("168.0.0.0", 6)
.addRoute("169.0.0.0", 9)
.addRoute("169.128.0.0", 10)
.addRoute("169.192.0.0", 11)
.addRoute("169.224.0.0", 12)
.addRoute("169.240.0.0", 13)
.addRoute("169.248.0.0", 14)
.addRoute("169.252.0.0", 15)
.addRoute("128.0.0.0", 3).addRoute("160.0.0.0", 5).addRoute("168.0.0.0", 6).addRoute("169.0.0.0", 9)
.addRoute("169.128.0.0", 10).addRoute("169.192.0.0", 11).addRoute("169.224.0.0", 12)
.addRoute("169.240.0.0", 13).addRoute("169.248.0.0", 14).addRoute("169.252.0.0", 15)
.addRoute("169.255.0.0", 16)
.addRoute("170.0.0.0", 7)
.addRoute("172.0.0.0", 12)
.addRoute("169.255.0.0", 16).addRoute("170.0.0.0", 7).addRoute("172.0.0.0", 12)
.addRoute("172.32.0.0", 11)
.addRoute("172.64.0.0", 10)
.addRoute("172.128.0.0", 9)
.addRoute("173.0.0.0", 8)
.addRoute("174.0.0.0", 7)
.addRoute("176.0.0.0", 4)
.addRoute("192.0.0.0", 9)
.addRoute("192.128.0.0", 11)
.addRoute("192.160.0.0", 13)
.addRoute("192.169.0.0", 16)
.addRoute("192.170.0.0", 15)
.addRoute("192.172.0.0", 14)
.addRoute("192.176.0.0", 12)
.addRoute("172.32.0.0", 11).addRoute("172.64.0.0", 10).addRoute("172.128.0.0", 9)
.addRoute("173.0.0.0", 8).addRoute("174.0.0.0", 7).addRoute("176.0.0.0", 4).addRoute("192.0.0.0", 9)
.addRoute("192.128.0.0", 11).addRoute("192.160.0.0", 13).addRoute("192.169.0.0", 16)
.addRoute("192.170.0.0", 15).addRoute("192.172.0.0", 14).addRoute("192.176.0.0", 12)
.addRoute("192.192.0.0", 10)
.addRoute("193.0.0.0", 8)
.addRoute("194.0.0.0", 7)
.addRoute("196.0.0.0", 6)
.addRoute("200.0.0.0", 5)
.addRoute("208.0.0.0", 4)
.addRoute("::", 0)
.addDnsServer("8.8.8.8")
.addDnsServer("114.114.114.114")
.addRoute("193.0.0.0", 8).addRoute("194.0.0.0", 7).addRoute("196.0.0.0", 6).addRoute("200.0.0.0", 5)
.addRoute("208.0.0.0", 4).addRoute("::", 0).addDnsServer("8.8.8.8").addDnsServer("114.114.114.114")
for (pkg in bypassPackages) {
if (pkg == packageName) continue
runCatching { builder.addDisallowedApplication(pkg) }
.onFailure { VpnLog.warn("忽略应用 $pkg 失败: ${it.message}") }
runCatching { builder.addDisallowedApplication(pkg) }.onFailure { VpnLog.warn("忽略应用 $pkg 失败: ${it.message}") }
}
pfd = builder.establish()
?: throw IllegalStateException("VpnService.establish() 返回 null")
pfd = builder.establish() ?: throw IllegalStateException("VpnService.establish() 返回 null")
activePfd = pfd
val fd = pfd.fd
VpnLog.info("网络代理已连接")
for ((index, step) in enabledSteps.withIndex()) {
if (stopRequested) {
VpnLog.warn("用户停止,终止后续步骤")
break
val stepsJson = JSONArray().apply {
enabledSteps.forEach { step ->
put(JSONObject().apply {
put("id", step.id)
put("hosts", JSONArray(step.hosts))
put("path", step.path)
put("savePath", captureFile(filesDir, profile, step).absolutePath)
put("repeat", continueRun)
})
}
}.toString()
val label = "[${index + 1}/${enabledSteps.size}] ${step.name}"
val target = captureFile(filesDir, profile, step)
target.parentFile?.mkdirs()
if (target.exists()) target.delete()
val capturedUrl = VpnBridge.runCapture(
fd,
step.hosts.joinToString(","),
step.path,
target.absolutePath,
proxy
)
if (capturedUrl == null) {
if (stopRequested) {
VpnLog.warn("$label 被用户停止")
break
if (!VpnBridge.boot(fd, stepsJson, proxy)) {
throw IllegalStateException("tun core 启动失败")
}
errorMsg = "$label 未抓到目标请求"
VpnLog.info("VPN 已建立")
latch.await()
if (continueRun) {
VpnLog.warn("持续运行结束(用户停止),已抓到 ${stepResults.size}/${enabledSteps.size} 步")
} else if (stopRequested) {
VpnLog.warn("用户停止,已抓到 ${stepResults.size}/${enabledSteps.size} 步")
} else if (stepResults.size < enabledSteps.size) {
errorMsg = "只抓到 ${stepResults.size}/${enabledSteps.size} 步"
VpnLog.error(errorMsg)
break
}
if (!target.exists() || target.length() == 0L) {
errorMsg = "$label 返回了 URL 但文件为空"
VpnLog.error(errorMsg)
break
}
lastFile = target
completed++
VpnLog.success("$label 抓取成功")
val result = CaptureResult(profile, step, capturedUrl, target)
postScope.launch {
try {
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 {
VpnBridge.stepListener = null
VpnBridge.stopSession()
VpnBridge.shutdownSession()
closeActivePfd()
VpnLog.info("==== 抓包结束($completed/${enabledSteps.size} 步完成)====")
VpnLog.info("==== 抓包结束(${stepResults.size}/${enabledSteps.size} 步完成)====")
if (errorMsg != null) {
CaptureStateHolder.setError(errorMsg)
} else if (completed == enabledSteps.size && lastFile != null) {
CaptureStateHolder.setCaptured(lastFile)
} else if (stepResults.size == enabledSteps.size && lastCaptured != null) {
CaptureStateHolder.setCaptured(lastCaptured!!)
} else {
CaptureStateHolder.setIdle()
}
allDone = null
stopSelf()
}
}, "capture-blocking-worker").apply { start() }
}
private fun handleStepResult(
profile: CaptureProfile,
step: CaptureProfileStep,
url: String?,
) {
val target = captureFile(filesDir, profile, step)
if (url == null || !target.exists() || target.length() == 0L) {
VpnLog.error("${step.name} 抓取失败")
return
}
stepResults[step.id] = url
lastCaptured = target
VpnLog.success("${step.name} 抓到目标请求")
val result = CaptureResult(profile, step, url, target)
postScope.launch {
try {
step.postAction(result)
} catch (c: CancellationException) {
VpnLog.warn("${step.name} 后处理被取消: ${c.message}")
throw c
} catch (t: Throwable) {
VpnLog.error("${step.name} 后处理失败: ${t.message}")
}
}
}
override fun onRevoke() {
stopRequested = true
VpnLog.warn("VPN 权限被撤销")
closeActivePfd()
VpnBridge.stopSession()
allDone?.countDown()
super.onRevoke()
}
override fun onDestroy() {
if (captureThread?.isAlive != true) {
VpnBridge.stopSession()
VpnBridge.shutdownSession()
closeActivePfd()
}
captureThread = null
super.onDestroy()
}
@@ -2,30 +2,14 @@ package net.bluemangoo.indpjsk.vpn
import android.util.Log
/**
* Rust core_vpn 的 JNI 桥。
*
* 契约(由 Rust lib.rs 实现):
* runCapture(jint tunFd, jstring targetHosts, jstring path, jstring savePath, jstring proxy) -> String?
* - 阻塞直到抓包结束
* - tunFd 的所有权【仍在 Kotlin 侧】,Rust 内部用 BorrowedAsyncDevice 借用,不得 close()
* - targetHosts 是逗号分隔的域名白名单(一个步骤可能对应多个上游域名)
* - proxy 可为 null / 空串(直连),否则必须是 http(s)://host:port 形式,Rust 侧会再校验一次
* - 返回非 null = 抓到目标请求,值是命中的原始完整 URL(https://host/path?query),文件已写入 savePath
* - 返回 null = 失败 / 被 requestStop 打断 / 未匹配
*
* requestStop() -> void
* - 立即返回,仅设置原子停止标志,使阻塞中的 runCapture 尽快返回 false
*
* nativeLog(String)(Kotlin -> Rust 回调的落地端)
* - Rust 通过 JNIEnv.call_static_method(".../VpnBridge", "nativeLog", ...) 调用
* - 用于把 TLS 握手 / 匹配结果等关键日志写进 UI 日志框
*/
object VpnBridge {
private const val TAG = "VpnBridge"
private var loaded = false
@Volatile
var stepListener: ((String, String?) -> Unit)? = null
init {
loaded = try {
System.loadLibrary("core_vpn")
@@ -39,18 +23,20 @@ object VpnBridge {
fun isAvailable(): Boolean = loaded
@JvmStatic
external fun runCapture(
tunFd: Int,
targetHosts: String,
targetPath: String,
savePath: String,
proxy: String?
): String?
external fun boot(tunFd: Int, stepsJson: String, proxy: String?): Boolean
@JvmStatic
external fun requestStop()
external fun stopSession()
@JvmStatic
external fun shutdownSession()
@Suppress("unused")
@JvmStatic
fun nativeStepResult(stepId: String, url: String?) {
stepListener?.invoke(stepId, url)
}
/** 供 Rust JNI 回调写入日志框;Rust 侧通过 call_static_method 调用本方法 */
@Suppress("unused")
@JvmStatic
fun nativeLog(level: String, msg: String) {
@@ -15,12 +15,6 @@ data class LogEntry(
val message: String
)
/**
* 全局日志容器:
* - Android 侧各处直接调用 info/warn/error/success
* - Rust 侧通过 JNI 静态方法 VpnBridge.nativeLog(String) 回调进来
* - UI 用 LazyColumn 消费 StateFlow,最多保留 MAX 条
*/
object VpnLog {
private const val MAX = 800
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z"/>
</vector>
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M200,760L257,760L648,369L591,312L200,703L200,760ZM120,840L120,670L648,143Q660,132 674.5,126Q689,120 705,120Q721,120 736,126Q751,132 762,144L817,200Q829,211 834.5,226Q840,241 840,256Q840,272 834.5,286.5Q829,301 817,313L290,840L120,840ZM760,256L760,256L704,200L704,200L760,256ZM619,341L591,312L591,312L648,369L648,369L619,341Z"/>
</vector>
+2
View File
@@ -278,6 +278,8 @@ dependencies = [
"rustls",
"rustls-native-certs",
"rustls-pemfile",
"serde",
"serde_json",
"tokio",
"tokio-rustls",
"tokio-util",
+3 -1
View File
@@ -13,7 +13,7 @@ tokio = { version = "1.47.5", features = ["full"] }
tokio-rustls = "0.26"
rustls-pemfile = "2"
jni = "0.22.4"
rcgen = "0.14"
rcgen = { version = "0.14", features = ["x509-parser"] }
anyhow = "1"
bytes = "1"
tokio-util = "0.7.20"
@@ -26,6 +26,8 @@ hyper-util = { version = "0.1", features = ["tokio", "server", "client"] }
http-body-util = "0.1"
http = "1.5.0"
qstring = "0.7.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
url = "2"
base64 = "0.23.1"
+4 -147
View File
@@ -1,156 +1,13 @@
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",
];
#[path = "src/cert.rs"]
mod cert;
use cert::*;
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);
let (fullchain_pem, leaf_key_pem) = sign(SAN.iter());
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");
}
}
+40
View File
@@ -0,0 +1,40 @@
use crate::cert::{check_cert_included, sign};
use crate::info;
use rustls_pemfile::{certs, private_key};
use std::io::BufReader;
use std::sync::Arc;
pub fn build_tls_acceptor(target_hosts: Vec<String>) -> Arc<rustls::ServerConfig> {
let is_included_built_in = check_cert_included(target_hosts.iter().map(|s| s.as_str()));
let (cert_chain, key) = if is_included_built_in {
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();
(cert_chain, key)
} else {
info!("Target hosts not match built-in cert, generating new cert");
let target = target_hosts.iter().map(|s| s.as_str()).collect::<Vec<_>>();
let (fullchain, leaf_key) = sign(target.iter());
let cert_chain = certs(&mut BufReader::new(fullchain.as_bytes()))
.collect::<Result<Vec<_>, _>>()
.unwrap();
let key = private_key(&mut BufReader::new(leaf_key.as_bytes()))
.unwrap()
.unwrap();
(cert_chain, key)
};
let mut config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(cert_chain, key)
.unwrap();
config.alpn_protocols = vec![b"http/1.1".to_vec()];
Arc::new(config)
}
+171 -19
View File
@@ -1,24 +1,176 @@
use rustls_pemfile::{certs, private_key};
use std::io::BufReader;
use std::sync::Arc;
use rcgen::{
CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair,
KeyUsagePurpose, SanType,
};
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"));
const CA_KEY: &str = "-----BEGIN PRIVATE KEY-----
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAShZANiAAQTglHNUqyS
mMHIqtl3Mh3rl+cJvQtMoKylXcitUdz8nRWJoVl+OlEg4e/WMcY+GDXKyuKYaaYu
FjHoooGBq1ZhbcRdkYq8CfOrDmPPeSqk3O1zh743u6VpVJ8cArJw7Wc=
-----END PRIVATE KEY-----";
let cert_chain = certs(&mut BufReader::new(&fullchain[..]))
.collect::<Result<Vec<_>, _>>()
.unwrap();
let key = private_key(&mut BufReader::new(&leaf_key[..]))
.unwrap()
.unwrap();
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-----";
let mut config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(cert_chain, key)
.unwrap();
pub 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",
];
config.alpn_protocols = vec![b"http/1.1".to_vec()];
Arc::new(config)
#[allow(unused)] // caused in re-build in build.rs
pub fn check_cert_included<'a>(target_hosts: impl Iterator<Item = &'a str>) -> bool {
for host in target_hosts {
if SAN.contains(&host) {
continue;
}
let mut found = false;
for included_host in SAN {
if !included_host.starts_with("*.") {
continue;
}
let without_star = &included_host[2..];
let host_without_first = host.split_once(".");
if let Some(parts) = host_without_first
&& parts.1 == without_star
{
found = true;
break;
}
}
if !found {
return false;
}
}
true
}
pub fn sign<'a>(target_hosts: impl Iterator<Item = &'a &'a str>) -> (String, String) {
let mut target_hosts = target_hosts.peekable();
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, target_hosts.peek().unwrap().to_string());
leaf_params.distinguished_name = dn;
leaf_params.subject_alt_names = target_hosts
.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");
let fullchain_pem = format!("{}{}", leaf_cert.pem(), CA_CHAIN);
let leaf_key_pem = leaf_key.serialize_pem();
(fullchain_pem, leaf_key_pem)
}
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");
}
}
+149 -64
View File
@@ -1,18 +1,18 @@
mod cert;
mod mitm;
mod tun;
mod mut_qstring;
mod tun;
mod acceptor;
use crate::mitm::{ProxyConfig, run_capture_async};
use crate::mitm::{ProxyConfig, StepSpec, Waiter, Waiters, run_session};
use jni::objects::{Global, JClass, JObject, JString};
use jni::sys::{jint, jstring};
use jni::errors::ThrowRuntimeExAndDefault;
use jni::sys::{jboolean, jint};
use jni::{Env, EnvUnowned, JValue, JavaVM, jni_sig, jni_str};
use regex::Regex;
use std::sync::{OnceLock, RwLock};
use std::sync::{Arc, Mutex, OnceLock};
use tokio_util::sync::CancellationToken;
static CANCLE_TOKEN: RwLock<Option<CancellationToken>> = RwLock::new(None);
static SESSION: Mutex<Option<Session>> = Mutex::new(None);
static JVM: OnceLock<JavaVM> = OnceLock::new();
static VPN_BRIDGE_CLASS: OnceLock<Global<JClass>> = OnceLock::new();
@@ -21,7 +21,6 @@ fn init_jvm(env: &mut Env) {
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) => {
@@ -80,101 +79,187 @@ macro_rules! error {
($($arg:tt)*) => {$crate::log_to_kotlin("error", &format!($($arg)*))};
}
struct Session {
cancel: CancellationToken,
waiters: Waiters,
done: std::sync::mpsc::Receiver<()>,
thread: Option<std::thread::JoinHandle<()>>,
}
pub fn notify_step_result(step_id: &str, url: Option<&str>) {
let Some(vm) = JVM.get() else { return };
let Some(class_ref) = VPN_BRIDGE_CLASS.get() else {
return;
};
let _ = vm.attach_current_thread(|env: &mut Env| -> Result<(), jni::errors::Error> {
let jid = env.new_string(step_id)?;
let jurl = match url {
Some(u) => Some(env.new_string(u)?),
None => None,
};
let jclass: &JClass = unsafe { &*(class_ref.as_obj() as *const JObject as *const JClass) };
let url_value = match &jurl {
Some(s) => JValue::Object(s),
None => JValue::Object(&JObject::null()),
};
env.call_static_method(
jclass,
jni_str!("nativeStepResult"),
jni_sig!("(Ljava/lang/String;Ljava/lang/String;)V"),
&[JValue::Object(&jid), url_value],
)?;
Ok(())
});
}
#[allow(non_snake_case)]
#[unsafe(no_mangle)]
pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_runCapture(
pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_boot(
mut env: EnvUnowned,
_class: JClass,
tun_fd: jint,
target_host: JString,
target_path: JString,
save_path: JString,
steps_json: JString,
proxy: JString,
) -> jstring {
) -> jboolean {
let _ = env.with_env(|env: &mut Env| -> Result<(), jni::errors::Error> {
init_jvm(env);
Ok(())
});
let host_arg: String = target_host.to_string();
let path: String = target_path.to_string();
let out: String = save_path.to_string();
let hosts: Vec<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();
if SESSION.lock().unwrap().is_some() {
error!("会话已在运行,忽略重复 boot");
return false;
}
let re = match Regex::new(&path) {
Ok(re) => re,
Err(e) => {
error!("Invalid regex pattern: {path} ({e})");
return std::ptr::null_mut();
}
};
let steps_raw: String = steps_json.to_string();
let proxy_raw: String = if proxy.is_null() {
String::new()
} else {
proxy.to_string()
};
let steps: Vec<StepSpec> = match serde_json::from_str(&steps_raw) {
Ok(v) => v,
Err(e) => {
error!("steps JSON 解析失败: {e}");
return false;
}
};
if steps.is_empty() {
error!("没有需要抓的步骤");
return false;
}
let proxy_cfg = match ProxyConfig::parse(&proxy_raw) {
Ok(cfg) => cfg,
Err(e) => {
error!("代理配置无效: {e}");
return std::ptr::null_mut();
return false;
}
};
if let Some(cfg) = &proxy_cfg {
info!("upstream proxy: {}://{}:{}", if cfg.tls { "https" } else { "http" }, cfg.host, cfg.port);
info!(
"upstream proxy: {}://{}:{}",
if cfg.tls { "https" } else { "http" },
cfg.host,
cfg.port
);
}
let mut hosts: Vec<String> = Vec::new();
let mut waiter_list: Vec<Waiter> = Vec::new();
for step in steps {
let re = match Regex::new(&step.path) {
Ok(re) => re,
Err(e) => {
error!("步骤 {} 的正则不合法: {e}", step.id);
return false;
}
};
for h in step.hosts {
if !h.is_empty() && !hosts.iter().any(|x| x.eq_ignore_ascii_case(&h)) {
hosts.push(h);
}
}
waiter_list.push(Waiter {
id: step.id,
path: re,
save_path: step.save_path,
repeat: step.repeat,
});
}
if hosts.is_empty() {
error!("所有步骤的 host 都是空的");
return false;
}
let cancel = CancellationToken::new();
let waiters: Waiters = Arc::new(Mutex::new(waiter_list));
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
let thread_waiters = waiters.clone();
let thread_cancel = cancel.clone();
let thread = match std::thread::Builder::new()
.name("vpn-session".into())
.spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let cancel_token = CancellationToken::new();
*CANCLE_TOKEN.write().unwrap() = Some(cancel_token.clone());
let cancel_token_clone = cancel_token.clone();
let result = rt.block_on(async move {
run_capture_async(tun_fd, hosts, re, out, proxy_cfg, cancel_token_clone).await
});
cancel_token.cancel();
if let Err(e) = rt.block_on(run_session(
tun_fd,
hosts,
proxy_cfg,
thread_waiters,
thread_cancel,
)) {
error!("session error: {e:?}");
}
rt.shutdown_timeout(std::time::Duration::from_secs(2));
let _ = done_tx.send(());
}) {
Ok(t) => t,
Err(e) => {
error!("启动 session 线程失败: {e}");
return false;
}
};
let url = result.unwrap_or_else(|e| {
error!("capture failed: {e:?}");
None
*SESSION.lock().unwrap() = Some(Session {
cancel,
waiters,
done: done_rx,
thread: Some(thread),
});
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()
true
}
#[allow(non_snake_case)]
#[unsafe(no_mangle)]
pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_requestStop(
pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_stopSession(
_env: EnvUnowned,
_class: JClass,
) {
info!("Stopping capture...");
if let Some(token) = CANCLE_TOKEN.write().unwrap().take() {
token.cancel()
info!("session stop requested");
if let Some(session) = SESSION.lock().unwrap().as_ref() {
session.waiters.lock().unwrap().clear();
session.cancel.cancel();
}
}
#[allow(non_snake_case)]
#[unsafe(no_mangle)]
pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_shutdownSession(
_env: EnvUnowned,
_class: JClass,
) {
let session = SESSION.lock().unwrap().take();
let Some(mut session) = session else { return };
session.waiters.lock().unwrap().clear();
session.cancel.cancel();
let _ = session.done.recv();
if let Some(t) = session.thread.take() {
let _ = t.join();
}
info!("session shutdown complete");
}
+144 -97
View File
@@ -1,8 +1,8 @@
use crate::cert::build_tls_acceptor;
use crate::mut_qstring::MutQString;
use crate::tun::{BorrowedTunIo, RewindStream};
use crate::{error, info, warn};
use bytes::Bytes;
use http::Uri;
use http_body_util::{BodyExt, Full};
use hyper::body::Incoming;
use hyper_util::rt::TokioIo;
@@ -12,19 +12,27 @@ use rustls::RootCertStore;
use std::io;
use std::net::SocketAddr;
use std::os::fd::RawFd;
use std::sync::{Arc, OnceLock};
use std::sync::{Arc, Mutex as StdMutex, OnceLock};
use std::time::Duration;
use http::Uri;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::UdpSocket;
use tokio::sync::Mutex;
use tokio::task::JoinSet;
use tokio_rustls::StartHandshake;
use tokio_util::sync::CancellationToken;
use tun_rs::BorrowedAsyncDevice;
use crate::acceptor::build_tls_acceptor;
static ROOT_STORE: OnceLock<Arc<RootCertStore>> = OnceLock::new();
const BLOCKED_HOSTS: &[&str] = &["submit.backtrace.io"];
fn is_blocked_host(host: &str) -> bool {
let host = host.trim_end_matches('.');
BLOCKED_HOSTS
.iter()
.any(|blocked| host.eq_ignore_ascii_case(blocked))
}
const SNIFF_TIMEOUT: Duration = Duration::from_secs(5);
const TEARDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(3);
const TEARDOWN_SETTLE: Duration = Duration::from_millis(50);
@@ -115,11 +123,9 @@ async fn handle_tls_connection<S>(
dst: SocketAddr,
upstream: Upstream,
target_hosts: Vec<String>,
target_path: Regex,
save_path: String,
waiters: Waiters,
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,
@@ -136,10 +142,16 @@ where
let Some((sni, accepted)) = sniffed else {
io.rewind();
io.stop_recording();
// 非 TLS:拿不到 SNI,走代理时只能按 IP CONNECT
return passthrough_cancel_wrapped(io, dst, None, upstream, &cancel).await;
};
if let Some(host) = sni.as_deref()
&& is_blocked_host(host)
{
warn!("blocked host {host} ({dst:?}), connection rejected");
return Ok(());
}
let host_matched = sni
.as_deref()
.map(|s| target_hosts.iter().any(|h| s.eq_ignore_ascii_case(h)))
@@ -154,7 +166,6 @@ where
}
return r;
}
info!("Encountered target host.");
io.stop_recording();
let client_tls = StartHandshake::from_parts(accepted, io)
@@ -162,16 +173,13 @@ where
.await?;
let svc_host = sni.clone().unwrap_or_default();
let svc_upstream = upstream;
let svc = hyper::service::service_fn(move |req: hyper::Request<Incoming>| {
let host = svc_host.clone();
let path = target_path.clone();
let save = save_path.clone();
let result_tx = result_tx.clone();
let upstream = svc_upstream.clone();
let waiters = waiters.clone();
let upstream = upstream.clone();
async move {
Ok::<_, std::convert::Infallible>(
forward_request(req, dst, host, path, save, result_tx, upstream).await,
forward_request(req, dst, host, waiters, upstream).await,
)
}
});
@@ -219,38 +227,42 @@ where
#[allow(clippy::too_many_arguments)]
async fn forward_request(
req: hyper::Request<Incoming>,
mut 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>>>>,
waiters: Waiters,
upstream: Upstream,
) -> hyper::Response<Full<Bytes>> {
match forward_request_inner(req, dst, &url_host, &target_path, &upstream).await {
Ok((response, matched_body)) => {
if let Some((url, body)) = matched_body {
info!("Target matched! Downloading {} bytes", body.len());
let path = std::path::Path::new(&save_path);
if !path.is_absolute() {
error!("Failed to resolve path: {save_path}");
if let Some(rewritten) = force_full_mysekai(req.uri()) {
warn!("rewrite mysekai flag: {} -> {}", req.uri(), rewritten);
*req.uri_mut() = rewritten;
}
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:?}"),
let uri = req.uri().clone();
let url = build_url(&url_host, &uri);
let path_and_query = uri
.path_and_query()
.map(|p| p.as_str().to_string())
.unwrap_or_else(|| "/".to_string());
let waiter = take_matching_waiter(&waiters, &path_and_query);
if let Some(w) = &waiter {
info!("step '{}' matched", w.id);
}
let want_body = waiter.is_some();
match forward_request_inner(req, dst, &url_host, upstream, want_body).await {
Ok((response, saved_body)) => {
if let (Some(w), Some(body)) = (waiter, saved_body) {
save_and_notify(w, url, body);
}
response
}
Err(e) => {
error!("forward to {dst} failed: {e:?}");
if let Some(w) = waiter {
crate::notify_step_result(&w.id, None);
}
hyper::Response::builder()
.status(hyper::StatusCode::BAD_GATEWAY)
.header("content-type", "text/plain; charset=utf-8")
@@ -260,28 +272,39 @@ async fn forward_request(
}
}
fn save_and_notify(waiter: Waiter, url: String, body: Bytes) {
tokio::spawn(async move {
let path = std::path::Path::new(&waiter.save_path);
if !path.is_absolute() {
error!("save_path 不是绝对路径({})", waiter.save_path);
}
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
match tokio::fs::write(path, &body).await {
Ok(()) => {
info!("step '{}' saved ({} bytes)", waiter.id, body.len());
crate::notify_step_result(&waiter.id, Some(&url));
}
Err(e) => {
error!("write {} failed: {e:?}", waiter.save_path);
crate::notify_step_result(&waiter.id, None);
}
}
});
}
async fn forward_request_inner(
req: hyper::Request<Incoming>,
dst: SocketAddr,
url_host: &str,
target_path: &Regex,
upstream: &Upstream,
) -> anyhow::Result<(hyper::Response<Full<Bytes>>, Option<(String, Bytes)>)> {
upstream: Upstream,
want_body: bool,
) -> anyhow::Result<(hyper::Response<Full<Bytes>>, Option<Bytes>)> {
let (mut parts, body) = req.into_parts();
if let Some(rewritten) = force_full_mysekai(&parts.uri) {
warn!("rewrite mysekai flag: {} -> {}", parts.uri, rewritten);
parts.uri = rewritten;
}
let parts_uri = parts.uri.clone();
let path_and_query = parts_uri
.path_and_query()
.map(|p| p.as_str().to_string())
.unwrap_or_else(|| "/".to_string());
let body_bytes = body.collect().await?.to_bytes();
// 拒绝长链接,反正抓到就跑。
// 拒绝长链接
for h in [
"connection",
"keep-alive",
@@ -298,7 +321,6 @@ async fn forward_request_inner(
let mut client_config = rustls::ClientConfig::builder()
.with_root_certificates(root_store())
.with_no_client_auth();
// 问就是有服务器不接受 H2
client_config.alpn_protocols = vec![b"http/1.1".to_vec()];
let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config));
let server_name = rustls::pki_types::ServerName::try_from(url_host.to_string())?;
@@ -330,17 +352,14 @@ async fn forward_request_inner(
res_parts.headers.remove(h);
}
let is_target = target_path.is_match(&path_and_query);
let url = build_url(url_host, &parts_uri);
let save = if is_target {
Some((url, res_bytes.clone()))
let saved = if want_body {
Some(res_bytes.clone())
} else {
None
};
Ok((
hyper::Response::from_parts(res_parts, Full::new(res_bytes)),
save,
saved,
))
}
@@ -352,18 +371,49 @@ fn build_url(host: &str, uri: &Uri) -> String {
format!("https://{host}{pq}")
}
/// 把 MySekai 的增量刷新标志强制改成 True(和模块的 URL Rewrite 一致),
/// 其它 query 参数原样保留。没有这个参数、或已经是 True 时返回 None。
fn force_full_mysekai(uri: &Uri) -> Option<Uri> {
const KEY: &str = "isForceAllReloadOnlyMysekai";
let mut query = qstring::QString::from(uri.query()?);
if !query.has(KEY) || query.get(KEY) == Some("True") {
if query.get(KEY) != Some("False") {
return None;
}
query.set(KEY, "True");
Uri::try_from(format!("{}?{}", uri.path(), query).as_str()).ok()
}
#[derive(serde::Deserialize, Debug, Clone)]
pub struct StepSpec {
pub id: String,
#[serde(default)]
pub hosts: Vec<String>,
pub path: String,
#[serde(rename = "savePath")]
pub save_path: String,
/// 持续运行:命中后不摘除 waiter,同类型的包可以反复抓
#[serde(default)]
pub repeat: bool,
}
#[derive(Clone)]
pub struct Waiter {
pub id: String,
pub path: Regex,
pub save_path: String,
pub repeat: bool,
}
pub type Waiters = Arc<StdMutex<Vec<Waiter>>>;
fn take_matching_waiter(waiters: &StdMutex<Vec<Waiter>>, path_and_query: &str) -> Option<Waiter> {
let mut list = waiters.lock().unwrap();
let idx = list.iter().position(|w| w.path.is_match(path_and_query))?;
if list[idx].repeat {
// 持续运行:保留 waiter,让后续同类型的请求继续命中
Some(list[idx].clone())
} else {
Some(list.remove(idx))
}
}
#[derive(Clone, Debug)]
pub struct ProxyConfig {
@@ -398,9 +448,16 @@ impl ProxyConfig {
None
} else {
let pass = url.password().unwrap_or("");
Some(base64_encode(format!("{}:{}", url.username(), pass).as_bytes()))
Some(base64_encode(
format!("{}:{}", url.username(), pass).as_bytes(),
))
};
Ok(Some(Self { tls, host, port, auth }))
Ok(Some(Self {
tls,
host,
port,
auth,
}))
}
}
@@ -421,7 +478,9 @@ struct Upstream {
impl Upstream {
fn new(proxy: Option<ProxyConfig>) -> Self {
Self { proxy: proxy.map(Arc::new) }
Self {
proxy: proxy.map(Arc::new),
}
}
async fn connect(&self, dst: SocketAddr, host: Option<&str>) -> anyhow::Result<UpstreamIo> {
@@ -503,14 +562,13 @@ async fn connect_via_proxy(
Ok(io)
}
pub async fn run_capture_async(
pub async fn run_session(
tun_fd: i32,
target_hosts: Vec<String>,
target_path: Regex,
save_path: String,
proxy: Option<ProxyConfig>,
cancel_token: CancellationToken,
) -> anyhow::Result<Option<String>> {
waiters: Waiters,
cancel: CancellationToken,
) -> anyhow::Result<()> {
let device = unsafe { BorrowedAsyncDevice::borrow_raw(tun_fd as RawFd)? };
let mut ipstack_config = ipstack::IpStackConfig::default();
@@ -520,22 +578,14 @@ pub async fn run_capture_async(
let mut ip_stack = ipstack::IpStack::new(ipstack_config, tun_io);
let mut bridge_tasks = JoinSet::new();
let tls_acceptor = build_tls_acceptor();
let tls_acceptor = build_tls_acceptor(target_hosts.clone());
let upstream = Upstream::new(proxy);
let (result_tx, mut result_rx) = tokio::sync::oneshot::channel::<String>();
let result_tx = Arc::new(Mutex::new(Some(result_tx)));
let result: anyhow::Result<Option<String>> = loop {
let token = cancel_token.clone();
loop {
let token = cancel.clone();
tokio::select! {
_ = cancel_token.cancelled() => {
let final_result = result_rx.try_recv().ok();
break Ok(final_result);
}
Ok(url) = &mut result_rx => {
cancel_token.cancel();
break Ok(Some(url));
_ = cancel.cancelled() => {
break;
}
_ = bridge_tasks.join_next(), if !bridge_tasks.is_empty() => {}
@@ -544,7 +594,7 @@ pub async fn run_capture_async(
Ok(s) => s,
Err(e) => {
error!("ip_stack accept error (device might be dropped): {e:?}");
break Err(anyhow::Error::from(e));
break;
}
};
@@ -554,7 +604,7 @@ pub async fn run_capture_async(
if dst.port() == 443 {
// drop quic roughly
drop(udp);
} else{
} else {
bridge_tasks.spawn(async move {
if let Err(e) = udp_passthrough(udp, dst, token).await {
error!("udp passthrough error: {e:?}");
@@ -567,7 +617,7 @@ pub async fn run_capture_async(
if dst.port() == 443 {
let (app_io, mut tun_io) = tokio::io::duplex(256 * 1024);
let pump_token = cancel_token.clone();
let pump_token = cancel.clone();
bridge_tasks.spawn(async move {
tokio::select! {
_ = pump_token.cancelled() => {
@@ -588,11 +638,9 @@ pub async fn run_capture_async(
});
let hosts = target_hosts.clone();
let path = target_path.clone();
let save = save_path.clone();
let server_config = tls_acceptor.clone();
let token = cancel_token.clone();
let result_tx = result_tx.clone();
let token = cancel.clone();
let waiters = waiters.clone();
let upstream = upstream.clone();
tokio::spawn(async move {
@@ -601,18 +649,16 @@ pub async fn run_capture_async(
dst,
upstream,
hosts,
path,
save,
waiters,
server_config,
token,
result_tx
).await {
Ok(()) => { }
Ok(()) => {}
Err(e) => error!("handle tls conn error: {e:?}"),
}
});
}else{
let pump_token = cancel_token.clone();
} else {
let pump_token = cancel.clone();
let upstream = upstream.clone();
bridge_tasks.spawn(async move {
let _ =
@@ -625,7 +671,7 @@ pub async fn run_capture_async(
}
}
}
};
}
let _ = tokio::time::timeout(TEARDOWN_GRACE, async {
while bridge_tasks.join_next().await.is_some() {}
@@ -634,7 +680,8 @@ pub async fn run_capture_async(
bridge_tasks.abort_all();
while bridge_tasks.join_next().await.is_some() {}
result
info!("session stopped");
Ok(())
}
async fn tcp_passthrough<IO>(
+49
View File
@@ -0,0 +1,49 @@
// noinspection JSUnusedGlobalSymbols
type Region = "cn" | "jp" | "en" | "tw" | "kr";
type Step = "suite" | "mysekai" | "birthday";
interface UploadUrlGeneral {
mode: "general";
url: string;
}
interface UploadUrlEach {
mode: "each";
suite: string | null;
mysekai: string | null;
birthday: string | null;
}
interface UploadMixinStep {
name: string;
hosts: (Region | string)[];
target: Step | string;
}
interface UploadMixinProfile {
name: string;
steps: UploadMixinStep[];
}
interface UploadMixinUploadIgnoreProfile {
name: Region;
steps: Step[];
}
interface UploadMixinUpload {
ignore?: "*" | (Region | UploadMixinUploadIgnoreProfile)[]; // never ignore defined profile in mixin context
}
interface UploadMixin {
profiles?: UploadMixinProfile[];
upload?: UploadMixinUpload;
}
export interface UploadProfileCustom {
type: "CUSTOM";
id: string;
uploadUrl: UploadUrlGeneral | UploadUrlEach;
headers: Record<string, "$original_url" | "$file_name" | "$user_id" | string>;
mixin?: UploadMixin;
}