4 Commits
Author SHA1 Message Date
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
27 changed files with 2639 additions and 497 deletions
+12 -4
View File
@@ -5,11 +5,11 @@ Rootless 开箱即用的 Project Sekai 用户数据抓包工具,支持上传
## 使用 ## 使用
- 安装并打开应用 - 安装并打开应用
- 如果需要上传,请在 [Haruki 工具箱](https://haruki.seiunx.com/upload-data) 中的 `iOS 模块` 方式里复制上传码并粘贴到应用中 - 如果需要上传,在右上角菜单的「上传设置」里添加上传配置。
- 在右上角选择需要的配置 - 选择需要的配置,可以取消勾选不需要的抓包步骤
- 可以取消勾选不需要的抓包步骤
- 点击开始,如果跳出建立 VPN 的提示,请允许 - 点击开始,如果跳出建立 VPN 的提示,请允许
- 启动游戏,进入游戏触发需要抓的包,应用会自动抓取数据并自动结束,如果配置了上传会自动上传 - 启动游戏,进入游戏触发需要抓的包,应用会自动抓取数据并自动结束
- 如果需要长时间反复抓同类型的包,在「代理设置」顶部勾选「持续运行」
## 触发位置 ## 触发位置
@@ -20,3 +20,11 @@ Rootless 开箱即用的 Project Sekai 用户数据抓包工具,支持上传
## 上游代理 ## 上游代理
你可能会需要上游代理,有时它是一个运行在本地的应用,此时为了防止冲突,需要将对应的应用加入绕过列表。 你可能会需要上游代理,有时它是一个运行在本地的应用,此时为了防止冲突,需要将对应的应用加入绕过列表。
在「代理设置」里填写代理地址后,还要勾选「使用上游代理」才会生效;地址为空时无法勾选(等同于直连)。
「持续运行」勾选后抓到包不会自动退出。
## 自定义上传配置
我们提供了三个Header的占位符:`$original_url` `$file_name` `$user_id`
+2 -2
View File
@@ -18,8 +18,8 @@ android {
applicationId = "net.bluemangoo.indpjsk" applicationId = "net.bluemangoo.indpjsk"
minSdk = appMinSdk minSdk = appMinSdk
targetSdk = 37 targetSdk = 37
versionCode = 3 versionCode = 6
versionName = "1.2" versionName = "1.5"
} }
@@ -25,10 +25,11 @@ import net.bluemangoo.indpjsk.ui.CaptureScreen
import net.bluemangoo.indpjsk.ui.CaptureViewModel import net.bluemangoo.indpjsk.ui.CaptureViewModel
import net.bluemangoo.indpjsk.ui.CapturedFilesScreen import net.bluemangoo.indpjsk.ui.CapturedFilesScreen
import net.bluemangoo.indpjsk.ui.ProxySettingsScreen import net.bluemangoo.indpjsk.ui.ProxySettingsScreen
import net.bluemangoo.indpjsk.ui.UploadSettingsScreen
import net.bluemangoo.indpjsk.ui.theme.IndPjskTheme import net.bluemangoo.indpjsk.ui.theme.IndPjskTheme
import java.io.File import java.io.File
enum class AppScreen { MAIN, PROXY, APP_SELECTION, FILES, ABOUT } enum class AppScreen { MAIN, PROXY, UPLOAD, APP_SELECTION, FILES, ABOUT }
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -43,7 +44,7 @@ class MainActivity : ComponentActivity() {
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
SettingsStore.loadSelectedProfileId(this) SettingsStore.selectedProfileId
?.let { PredefinedProfiles.byId(it) } ?.let { PredefinedProfiles.byId(it) }
?.let { CaptureStateHolder.selectProfile(it) } ?.let { CaptureStateHolder.selectProfile(it) }
@@ -52,7 +53,7 @@ class MainActivity : ComponentActivity() {
IndPjskTheme { IndPjskTheme {
var screen by rememberSaveable { mutableStateOf(AppScreen.MAIN) } var screen by rememberSaveable { mutableStateOf(AppScreen.MAIN) }
var bypassPackages by remember { var bypassPackages by remember {
mutableStateOf(SettingsStore.loadBypassPackages(this)) mutableStateOf(SettingsStore.bypassPackages)
} }
val readme = remember { val readme = remember {
runCatching { runCatching {
@@ -73,15 +74,20 @@ class MainActivity : ComponentActivity() {
onStartCapture = { requestVpnAndStart() }, onStartCapture = { requestVpnAndStart() },
onStopCapture = { viewModel.stopCapture(this) }, onStopCapture = { viewModel.stopCapture(this) },
onOpenProxySettings = { screen = AppScreen.PROXY }, onOpenProxySettings = { screen = AppScreen.PROXY },
onOpenUploadSettings = { screen = AppScreen.UPLOAD },
onOpenFiles = { screen = AppScreen.FILES }, onOpenFiles = { screen = AppScreen.FILES },
onOpenAbout = { screen = AppScreen.ABOUT }, onOpenAbout = { screen = AppScreen.ABOUT },
) )
AppScreen.UPLOAD -> UploadSettingsScreen(
onBack = { screen = AppScreen.MAIN },
)
AppScreen.PROXY -> ProxySettingsScreen( AppScreen.PROXY -> ProxySettingsScreen(
bypassPackages = bypassPackages, bypassPackages = bypassPackages,
onBypassChange = { newSet -> onBypassChange = { newSet ->
bypassPackages = newSet bypassPackages = newSet
SettingsStore.saveBypassPackages(this, newSet) SettingsStore.bypassPackages = newSet
}, },
onSelectApps = { screen = AppScreen.APP_SELECTION }, onSelectApps = { screen = AppScreen.APP_SELECTION },
onBack = { screen = AppScreen.MAIN }, onBack = { screen = AppScreen.MAIN },
@@ -95,7 +101,7 @@ class MainActivity : ComponentActivity() {
} else { } else {
bypassPackages + entry.packageName bypassPackages + entry.packageName
} }
SettingsStore.saveBypassPackages(this, bypassPackages) SettingsStore.bypassPackages = bypassPackages
}, },
onBack = { screen = AppScreen.PROXY }, onBack = { screen = AppScreen.PROXY },
) )
@@ -15,6 +15,8 @@ data class CaptureProfileStep(
val path: String, val path: String,
val fileName: String, val fileName: String,
var postAction: suspend (CaptureResult) -> Unit = {}, var postAction: suspend (CaptureResult) -> Unit = {},
val uploadType: String? = null,
val explicitUploadIds: Set<String> = emptySet(),
) )
data class CaptureResult( data class CaptureResult(
@@ -1,7 +1,6 @@
package net.bluemangoo.indpjsk.data package net.bluemangoo.indpjsk.data
import android.content.Context import android.content.Context
import android.content.pm.ApplicationInfo
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
data class AppEntry( data class AppEntry(
@@ -16,10 +15,6 @@ object InstalledApps {
val pm = context.packageManager val pm = context.packageManager
return pm.getInstalledApplications(0) return pm.getInstalledApplications(0)
.asSequence() .asSequence()
.filter { info ->
(info.flags and ApplicationInfo.FLAG_SYSTEM) == 0 ||
(info.flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) != 0
}
.mapNotNull { info -> .mapNotNull { info ->
runCatching { runCatching {
AppEntry( AppEntry(
@@ -0,0 +1,186 @@
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 ((_, profile) in UploadStore.load()) {
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>()
}
}
@@ -3,9 +3,28 @@ package net.bluemangoo.indpjsk.data
import net.bluemangoo.indpjsk.upload.Uploader import net.bluemangoo.indpjsk.upload.Uploader
object PredefinedProfiles { object PredefinedProfiles {
private const val SUITE_PATH = "/api/suite/user/(\\d+)(\\?isLogin=true)?\$" private const val SUITE_PATH = "/api/suite/user/(\\d+)(\\?isLogin=true)?$"
private const val MYSEKAI_PATH = private const val MYSEKAI_PATH =
"/api/user/(\\d+)/mysekai\\?isForceAllReloadOnlyMysekai=True" "/api/user/(\\d+)/mysekai\\?isForceAllReloadOnlyMysekai=True"
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( private fun suiteStep(hosts: List<String>) = CaptureProfileStep(
id = "suite", id = "suite",
@@ -33,14 +52,17 @@ object PredefinedProfiles {
id = "mysekai_birthday", id = "mysekai_birthday",
name = "烤森生日双叶", name = "烤森生日双叶",
hosts = hosts, hosts = hosts,
path = "/api/user/(\\d+)/mysekai/birthday-party/(\\d+)/delivery", path = MYSEKAI_BIRTHDAY_PATH,
fileName = "mysekai_birthday", fileName = "mysekai_birthday",
postAction = { result -> postAction = { result ->
Uploader.tryUpload(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 JP_HOSTS = listOf("production-game-api.sekai.colorfulpalette.org")
private val EN_HOSTS = listOf("n-production-game-api.sekai-en.com") private val EN_HOSTS = listOf("n-production-game-api.sekai-en.com")
private val TW_HOSTS = listOf( private val TW_HOSTS = listOf(
@@ -105,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 package net.bluemangoo.indpjsk.data
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.edit import androidx.core.content.edit
import net.bluemangoo.indpjsk.App
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KClass
import kotlin.reflect.KProperty
/** 全局配置持久化:上传码、上游代理、忽略的应用、选中的配置 */
object SettingsStore { object SettingsStore {
private fun prefs() =
App.appContext.getSharedPreferences("settings", Context.MODE_PRIVATE)
private const val PREFS = "settings" private inline fun <reified T> pref(
private const val KEY_UPLOAD_CODE = "upload_code" default: T? = null,
private const val KEY_PROXY = "upstream_proxy" key: String? = null
private const val KEY_BYPASS = "bypass_packages" ) = PrefProperty(
private const val KEY_PROFILE = "selected_profile" clazz = T::class,
customDefault = default,
customKey = key,
prefs = { prefs() }
)
private fun prefs(context: Context) = var uploadProfilesJson by pref<String>()
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) 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) { class PrefProperty<T>(
prefs(context).edit { putString(KEY_UPLOAD_CODE, code) } 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}")
}
} }
// ---- 上游代理(空 = 直连) ---- @Suppress("UNCHECKED_CAST")
fun loadProxy(context: Context): String = private fun getOrCreateState(property: KProperty<*>): MutableState<T> {
prefs(context).getString(KEY_PROXY, "").orEmpty() return internalState ?: run {
val key = getKey(property)
val sp = prefs()
val fallback = getDefault()
fun saveProxy(context: Context, proxy: String) { val initialVal: T = when {
prefs(context).edit { putString(KEY_PROXY, proxy.trim()) } 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 的应用(包名) ---- else -> fallback
fun loadBypassPackages(context: Context): Set<String> = }
prefs(context).getStringSet(KEY_BYPASS, emptySet())?.toSet() ?: emptySet() mutableStateOf(initialVal).also { internalState = it }
}
fun saveBypassPackages(context: Context, packages: Set<String>) {
prefs(context).edit { putStringSet(KEY_BYPASS, packages) }
} }
// ---- 当前选中的配置 ---- @Suppress("UNCHECKED_CAST")
fun loadSelectedProfileId(context: Context): String? = private fun saveToSp(value: T) {
prefs(context).getString(KEY_PROFILE, null) 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) { override fun getValue(thisRef: Any?, property: KProperty<*>): T {
prefs(context).edit { putString(KEY_PROFILE, id) } 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 org.json.JSONObject
import androidx.core.content.edit import androidx.core.content.edit
/**
* 每个 profile 下每个步骤「要不要抓」的持久化。
*
* 就一个 JSON 字符串塞进 SharedPreferences,键是 "profileId/stepId",值 Boolean,
* 没存过的默认 true(全抓)。
*/
object StepSelectionStore { object StepSelectionStore {
private const val PREFS = "step_selection" 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.res.painterResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import net.bluemangoo.indpjsk.R import net.bluemangoo.indpjsk.R
import net.bluemangoo.indpjsk.data.CaptureProfile import net.bluemangoo.indpjsk.data.CaptureProfile
import net.bluemangoo.indpjsk.data.CaptureProfileStep import net.bluemangoo.indpjsk.data.CaptureProfileStep
import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.data.StepSelectionStore import net.bluemangoo.indpjsk.data.StepSelectionStore
import net.bluemangoo.indpjsk.state.CaptureStatus import net.bluemangoo.indpjsk.state.CaptureStatus
import net.bluemangoo.indpjsk.vpn.LogEntry import net.bluemangoo.indpjsk.vpn.LogEntry
@@ -36,6 +34,7 @@ fun CaptureScreen(
onStartCapture: () -> Unit, onStartCapture: () -> Unit,
onStopCapture: () -> Unit, onStopCapture: () -> Unit,
onOpenProxySettings: () -> Unit, onOpenProxySettings: () -> Unit,
onOpenUploadSettings: () -> Unit,
onOpenFiles: () -> Unit, onOpenFiles: () -> Unit,
onOpenAbout: () -> Unit, onOpenAbout: () -> Unit,
) { ) {
@@ -46,7 +45,6 @@ fun CaptureScreen(
var enabledMap by remember { mutableStateOf(StepSelectionStore.loadAll(context) as Map<String, Boolean>) } var enabledMap by remember { mutableStateOf(StepSelectionStore.loadAll(context) as Map<String, Boolean>) }
var menuExpanded by remember { mutableStateOf(false) } var menuExpanded by remember { mutableStateOf(false) }
var profileExpanded by remember { mutableStateOf(false) } var profileExpanded by remember { mutableStateOf(false) }
var showUploadCodeDialog by remember { mutableStateOf(false) }
val isRunning = state.status == CaptureStatus.RUNNING val isRunning = state.status == CaptureStatus.RUNNING
val isCaptured = state.status == CaptureStatus.CAPTURED val isCaptured = state.status == CaptureStatus.CAPTURED
@@ -69,10 +67,10 @@ fun CaptureScreen(
onDismissRequest = { menuExpanded = false } onDismissRequest = { menuExpanded = false }
) { ) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("上传码") }, text = { Text("上传设置") },
onClick = { onClick = {
menuExpanded = false menuExpanded = false
showUploadCodeDialog = true onOpenUploadSettings()
} }
) )
DropdownMenuItem( DropdownMenuItem(
@@ -143,7 +141,7 @@ fun CaptureScreen(
DropdownMenuItem( DropdownMenuItem(
text = { Text(profile.name) }, text = { Text(profile.name) },
onClick = { onClick = {
viewModel.selectProfile(context, profile) viewModel.selectProfile(profile)
profileExpanded = false 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 @Composable
@@ -5,15 +5,17 @@ import android.content.Intent
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import net.bluemangoo.indpjsk.data.CaptureProfile import net.bluemangoo.indpjsk.data.CaptureProfile
import net.bluemangoo.indpjsk.data.MixinProfiles
import net.bluemangoo.indpjsk.data.PredefinedProfiles import net.bluemangoo.indpjsk.data.PredefinedProfiles
import net.bluemangoo.indpjsk.data.SettingsStore import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.state.CaptureStateHolder import net.bluemangoo.indpjsk.state.CaptureStateHolder
import net.bluemangoo.indpjsk.state.CaptureStatus import net.bluemangoo.indpjsk.state.CaptureStatus
import net.bluemangoo.indpjsk.upload.UploadStore
import net.bluemangoo.indpjsk.vpn.CaptureVpnService import net.bluemangoo.indpjsk.vpn.CaptureVpnService
import net.bluemangoo.indpjsk.vpn.VpnLog import net.bluemangoo.indpjsk.vpn.VpnLog
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import java.io.File import java.io.File
@@ -27,28 +29,32 @@ data class CaptureUiState(
class CaptureViewModel : ViewModel() { class CaptureViewModel : ViewModel() {
val uiState: StateFlow<CaptureUiState> = CaptureStateHolder.state private fun allProfiles(): List<CaptureProfile> = MixinProfiles.merged()
.map { s ->
val uiState: StateFlow<CaptureUiState> = combine(
CaptureStateHolder.state,
UploadStore.revision,
) { s, _ ->
val profiles = allProfiles()
CaptureUiState( CaptureUiState(
profiles = PredefinedProfiles.all, profiles = profiles,
selectedProfile = s.profile, selectedProfile = profiles.firstOrNull { it.id == s.profile.id } ?: s.profile,
status = s.status, status = s.status,
lastCapturedFile = s.lastCapturedFile, lastCapturedFile = s.lastCapturedFile,
errorMessage = s.errorMessage errorMessage = s.errorMessage
) )
} }.stateIn(
.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
initialValue = CaptureUiState( initialValue = CaptureUiState(
profiles = PredefinedProfiles.all, profiles = allProfiles(),
selectedProfile = PredefinedProfiles.all.firstOrNull() selectedProfile = PredefinedProfiles.all.firstOrNull()
) )
) )
fun selectProfile(context: Context, profile: CaptureProfile) { fun selectProfile(profile: CaptureProfile) {
CaptureStateHolder.selectProfile(profile) CaptureStateHolder.selectProfile(profile)
SettingsStore.saveSelectedProfileId(context, profile.id) SettingsStore.selectedProfileId = profile.id
} }
fun startCapture(context: Context) { fun startCapture(context: Context) {
@@ -18,6 +18,7 @@ import net.bluemangoo.indpjsk.R
import net.bluemangoo.indpjsk.data.AppEntry import net.bluemangoo.indpjsk.data.AppEntry
import net.bluemangoo.indpjsk.data.InstalledApps import net.bluemangoo.indpjsk.data.InstalledApps
import net.bluemangoo.indpjsk.data.SettingsStore import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.util.asState
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -28,7 +29,9 @@ fun ProxySettingsScreen(
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val context = LocalContext.current 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) } var pendingRemove by remember { mutableStateOf<AppEntry?>(null) }
val entries = remember(bypassPackages) { val entries = remember(bypassPackages) {
@@ -37,6 +40,7 @@ fun ProxySettingsScreen(
val looksValid = proxy.isBlank() || val looksValid = proxy.isBlank() ||
((proxy.startsWith("http://") || proxy.startsWith("https://")) && proxy.contains(":")) ((proxy.startsWith("http://") || proxy.startsWith("https://")) && proxy.contains(":"))
val canUseProxy = proxy.isNotBlank() && looksValid
Scaffold( Scaffold(
topBar = { topBar = {
@@ -60,17 +64,61 @@ fun ProxySettingsScreen(
.padding(horizontal = 0.dp, vertical = 12.dp), .padding(horizontal = 0.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(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( Column(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Text("上游代理", style = MaterialTheme.typography.labelLarge) 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( OutlinedTextField(
value = proxy, value = proxy,
onValueChange = { onValueChange = {
proxy = it proxy = it.trim()
SettingsStore.saveProxy(context, it)
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
@@ -79,10 +127,9 @@ fun ProxySettingsScreen(
isError = !looksValid, isError = !looksValid,
supportingText = { supportingText = {
Text( Text(
text = if (looksValid) { text = when {
"留空以忽略;只支持 http / https 代理" !looksValid -> "格式示例:http://127.0.0.1:7890"
} else { else -> "只支持 http / https 代理"
"格式示例:http://127.0.0.1:7890"
} }
) )
} }
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,96 +1,118 @@
package net.bluemangoo.indpjsk.upload package net.bluemangoo.indpjsk.upload
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.bluemangoo.indpjsk.App
import net.bluemangoo.indpjsk.data.CaptureResult import net.bluemangoo.indpjsk.data.CaptureResult
import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.vpn.VpnLog import net.bluemangoo.indpjsk.vpn.VpnLog
import org.json.JSONObject
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.io.RandomAccessFile import java.io.RandomAccessFile
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.util.*
import kotlin.random.Random 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 { 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 CHUNK_SIZE = 1024 * 1024
private const val SCRIPT_VERSION = "1.0.1"
suspend fun upload( suspend fun tryUpload(result: CaptureResult, onlyProfileIds: Set<String>? = null) {
uploadCode: String, 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, originalUrl: String,
file: File, file: File,
) = withContext(Dispatchers.IO) { type: String? = null,
): Boolean = withContext(Dispatchers.IO) {
require(file.exists()) { "文件不存在: ${file.absolutePath}" } 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() val uploadId = randomUploadId()
RandomAccessFile(file, "r").use { raf -> RandomAccessFile(file, "r").use { raf ->
val size = raf.length() val size = raf.length()
val total = if (size == 0L) 1 else ((size + CHUNK_SIZE - 1) / CHUNK_SIZE).toInt() val total = if (size == 0L) 1 else ((size + CHUNK_SIZE - 1) / CHUNK_SIZE).toInt()
val buf = ByteArray(CHUNK_SIZE) val buf = ByteArray(CHUNK_SIZE)
VpnLog.info("${file.name} 上传开始") VpnLog.info("[${profile.id}] ${file.name} 上传开始 (${size} bytes)")
for (index in 0 until total) { for (index in 0 until total) {
val remaining = size - raf.filePointer val remaining = size - raf.filePointer
val len = if (remaining <= 0L) 0 else minOf(CHUNK_SIZE.toLong(), remaining).toInt() val len = if (remaining <= 0L) 0 else minOf(CHUNK_SIZE.toLong(), remaining).toInt()
if (len > 0) raf.readFully(buf, 0, len) 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") if (code != 200) throw IOException("${file.name} 上传失败: HTTP $code")
} }
VpnLog.info("${file.name} 上传完成")
}
}
suspend fun tryUpload(result: CaptureResult) {
val uploadCode = SettingsStore.loadUploadCode(App.appContext).trim()
if (uploadCode.isNotEmpty()) {
VpnLog.info("${result.profile.name}${result.step.name} 开始上传")
upload(uploadCode, result.url, result.file)
VpnLog.success("${result.profile.name}${result.step.name} 上传完成")
} }
true
} }
private fun postChunk( private fun postChunk(
uploadCode: String, profile: UploadProfile,
url: String,
uploadId: String, uploadId: String,
index: Int, index: Int,
total: Int, total: Int,
originalUrl: String, originalUrl: String,
data: ByteArray, data: ByteArray,
file: File,
length: Int, length: Int,
): Int { ): Int {
val conn = URL("$BASE/$uploadCode/upload").openConnection() as HttpURLConnection val conn = URL(url).openConnection() as HttpURLConnection
try { try {
conn.requestMethod = "POST" conn.requestMethod = "POST"
conn.doOutput = true conn.doOutput = true
conn.instanceFollowRedirects = false conn.instanceFollowRedirects = true
conn.connectTimeout = 15_000 conn.connectTimeout = 15_000
conn.readTimeout = 60_000 conn.readTimeout = 60_000
conn.setRequestProperty("Content-Type", "application/octet-stream") 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-Upload-Id", uploadId)
conn.setRequestProperty("X-Chunk-Index", index.toString()) conn.setRequestProperty("X-Chunk-Index", index.toString())
conn.setRequestProperty("X-Total-Chunks", total.toString()) conn.setRequestProperty("X-Total-Chunks", total.toString())
conn.setRequestProperty("X-Original-Url", originalUrl) conn.setRequestProperty("X-Original-Url", originalUrl)
conn.setFixedLengthStreamingMode(length) conn.setFixedLengthStreamingMode(length)
profile.preRequest(conn, originalUrl, file)
conn.outputStream.use { it.write(data, 0, length) } conn.outputStream.use { it.write(data, 0, length) }
val code = conn.responseCode val code = conn.responseCode
@@ -101,7 +123,7 @@ object Uploader {
?.use { it.readText() } ?.use { it.readText() }
?.take(300) ?.take(300)
}.getOrNull().orEmpty() }.getOrNull().orEmpty()
throw IOException("分片 ${index + 1}/$total 上传失败: HTTP $code $body") throw IOException("${profile.id} 分片 ${index + 1}/$total 上传失败: HTTP $code $body")
} }
return code return code
} finally { } finally {
@@ -109,7 +131,6 @@ object Uploader {
} }
} }
/** 9 位随机字母数字,同一文件所有分片共用一个 */
private fun randomUploadId(): String { private fun randomUploadId(): String {
val chars = "0123456789abcdefghijklmnopqrstuvwxyz" val chars = "0123456789abcdefghijklmnopqrstuvwxyz"
return buildString(9) { return buildString(9) {
@@ -117,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
@@ -7,7 +7,11 @@ import android.os.ParcelFileDescriptor
import kotlinx.coroutines.* import kotlinx.coroutines.*
import net.bluemangoo.indpjsk.data.* import net.bluemangoo.indpjsk.data.*
import net.bluemangoo.indpjsk.state.CaptureStateHolder import net.bluemangoo.indpjsk.state.CaptureStateHolder
import org.json.JSONArray
import org.json.JSONObject
import java.io.File import java.io.File
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
@SuppressLint("VpnServicePolicy") @SuppressLint("VpnServicePolicy")
class CaptureVpnService : VpnService() { class CaptureVpnService : VpnService() {
@@ -23,9 +27,7 @@ class CaptureVpnService : VpnService() {
private val postScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val postScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun captureFile( fun captureFile(
filesDir: File, filesDir: File, profile: CaptureProfile, step: CaptureProfileStep
profile: CaptureProfile,
step: CaptureProfileStep
): File = File(File(File(filesDir, "captures"), profile.id), step.fileName) ): File = File(File(File(filesDir, "captures"), profile.id), step.fileName)
} }
@@ -38,6 +40,16 @@ class CaptureVpnService : VpnService() {
@Volatile @Volatile
private var stopRequested = false private var stopRequested = false
private val stepResults = ConcurrentHashMap<String, String>()
private val finishedSteps = ConcurrentHashMap.newKeySet<String>()
@Volatile
private var allDone: CountDownLatch? = null
@Volatile
private var lastCaptured: File? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) { when (intent?.action) {
@@ -49,12 +61,14 @@ class CaptureVpnService : VpnService() {
ACTION_STOP -> { ACTION_STOP -> {
stopRequested = true stopRequested = true
VpnLog.warn("手动停止…") VpnBridge.stopSession()
VpnBridge.requestStop() allDone?.countDown()
closeActivePfd()
CaptureStateHolder.setIdle() CaptureStateHolder.setIdle()
if (captureThread?.isAlive != true) {
closeActivePfd()
stopSelf() stopSelf()
} }
}
else -> return START_NOT_STICKY else -> return START_NOT_STICKY
} }
@@ -66,7 +80,6 @@ class CaptureVpnService : VpnService() {
activePfd = null activePfd = null
try { try {
pfd?.close() pfd?.close()
VpnBridge.requestStop()
} catch (t: Throwable) { } catch (t: Throwable) {
VpnLog.warn("关闭代理失败: ${t.message}") VpnLog.warn("关闭代理失败: ${t.message}")
} }
@@ -88,22 +101,50 @@ class CaptureVpnService : VpnService() {
return return
} }
val proxy = SettingsStore.loadProxy(this).trim() val proxy: String? = if (SettingsStore.useProxy) {
val bypassPackages = SettingsStore.loadBypassPackages(this) SettingsStore.upstreamProxy.trim().takeIf { it.isNotEmpty() }
if (proxy.isNotEmpty()) VpnLog.info("上游代理: $proxy") } else {
null
}
val bypassPackages = SettingsStore.bypassPackages
if (proxy != null) {
VpnLog.info("上游代理: $proxy")
} else {
VpnLog.info("未启用上游代理")
}
if (bypassPackages.isNotEmpty()) { if (bypassPackages.isNotEmpty()) {
VpnLog.info("忽略的应用: ${bypassPackages.joinToString(", ")}") VpnLog.info("忽略的应用: ${bypassPackages.joinToString(", ")}")
} }
stopRequested = false stopRequested = false
stepResults.clear()
finishedSteps.clear()
lastCaptured = null
val latch = CountDownLatch(1)
allDone = latch
val continueRun = SettingsStore.continueRun
VpnLog.info("==== 开始抓包 ====") VpnLog.info("==== 开始抓包 ====")
VpnLog.info("配置: ${profile.name}") 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({ captureThread = Thread({
var pfd: ParcelFileDescriptor? var pfd: ParcelFileDescriptor?
var lastFile: File? = null
var completed = 0
var errorMsg: String? = null var errorMsg: String? = null
try { try {
@@ -111,159 +152,133 @@ class CaptureVpnService : VpnService() {
throw IllegalStateException("Rust 动态库 core_vpn 未加载") throw IllegalStateException("Rust 动态库 core_vpn 未加载")
} }
val builder = Builder() val builder = Builder().setSession("Capture-${profile.id}").addAddress(VPN_ADDRESS, VPN_PREFIX)
.setSession("Capture-${profile.id}") .addDisallowedApplication(packageName).addRoute("1.0.0.0", 8)
.addAddress(VPN_ADDRESS, VPN_PREFIX)
.addDisallowedApplication(packageName)
.addRoute("1.0.0.0", 8)
.addRoute("2.0.0.0", 7) .addRoute("2.0.0.0", 7).addRoute("4.0.0.0", 6).addRoute("8.0.0.0", 7)
.addRoute("4.0.0.0", 6)
.addRoute("8.0.0.0", 7)
.addRoute("11.0.0.0", 8) .addRoute("11.0.0.0", 8).addRoute("12.0.0.0", 6).addRoute("16.0.0.0", 4).addRoute("32.0.0.0", 3)
.addRoute("12.0.0.0", 6) .addRoute("64.0.0.0", 5).addRoute("96.0.0.0", 10)
.addRoute("16.0.0.0", 4)
.addRoute("32.0.0.0", 3)
.addRoute("64.0.0.0", 5)
.addRoute("96.0.0.0", 10)
.addRoute("100.128.0.0", 9) .addRoute("100.128.0.0", 9).addRoute("101.0.0.0", 8).addRoute("102.0.0.0", 7)
.addRoute("101.0.0.0", 8) .addRoute("104.0.0.0", 5).addRoute("112.0.0.0", 4)
.addRoute("102.0.0.0", 7)
.addRoute("104.0.0.0", 5)
.addRoute("112.0.0.0", 4)
.addRoute("128.0.0.0", 3) .addRoute("128.0.0.0", 3).addRoute("160.0.0.0", 5).addRoute("168.0.0.0", 6).addRoute("169.0.0.0", 9)
.addRoute("160.0.0.0", 5) .addRoute("169.128.0.0", 10).addRoute("169.192.0.0", 11).addRoute("169.224.0.0", 12)
.addRoute("168.0.0.0", 6) .addRoute("169.240.0.0", 13).addRoute("169.248.0.0", 14).addRoute("169.252.0.0", 15)
.addRoute("169.0.0.0", 9)
.addRoute("169.128.0.0", 10)
.addRoute("169.192.0.0", 11)
.addRoute("169.224.0.0", 12)
.addRoute("169.240.0.0", 13)
.addRoute("169.248.0.0", 14)
.addRoute("169.252.0.0", 15)
.addRoute("169.255.0.0", 16) .addRoute("169.255.0.0", 16).addRoute("170.0.0.0", 7).addRoute("172.0.0.0", 12)
.addRoute("170.0.0.0", 7)
.addRoute("172.0.0.0", 12)
.addRoute("172.32.0.0", 11) .addRoute("172.32.0.0", 11).addRoute("172.64.0.0", 10).addRoute("172.128.0.0", 9)
.addRoute("172.64.0.0", 10) .addRoute("173.0.0.0", 8).addRoute("174.0.0.0", 7).addRoute("176.0.0.0", 4).addRoute("192.0.0.0", 9)
.addRoute("172.128.0.0", 9) .addRoute("192.128.0.0", 11).addRoute("192.160.0.0", 13).addRoute("192.169.0.0", 16)
.addRoute("173.0.0.0", 8) .addRoute("192.170.0.0", 15).addRoute("192.172.0.0", 14).addRoute("192.176.0.0", 12)
.addRoute("174.0.0.0", 7)
.addRoute("176.0.0.0", 4)
.addRoute("192.0.0.0", 9)
.addRoute("192.128.0.0", 11)
.addRoute("192.160.0.0", 13)
.addRoute("192.169.0.0", 16)
.addRoute("192.170.0.0", 15)
.addRoute("192.172.0.0", 14)
.addRoute("192.176.0.0", 12)
.addRoute("192.192.0.0", 10) .addRoute("192.192.0.0", 10)
.addRoute("193.0.0.0", 8) .addRoute("193.0.0.0", 8).addRoute("194.0.0.0", 7).addRoute("196.0.0.0", 6).addRoute("200.0.0.0", 5)
.addRoute("194.0.0.0", 7) .addRoute("208.0.0.0", 4).addRoute("::", 0).addDnsServer("8.8.8.8").addDnsServer("114.114.114.114")
.addRoute("196.0.0.0", 6)
.addRoute("200.0.0.0", 5)
.addRoute("208.0.0.0", 4)
.addRoute("::", 0)
.addDnsServer("8.8.8.8")
.addDnsServer("114.114.114.114")
for (pkg in bypassPackages) { for (pkg in bypassPackages) {
if (pkg == packageName) continue if (pkg == packageName) continue
runCatching { builder.addDisallowedApplication(pkg) } runCatching { builder.addDisallowedApplication(pkg) }.onFailure { VpnLog.warn("忽略应用 $pkg 失败: ${it.message}") }
.onFailure { VpnLog.warn("忽略应用 $pkg 失败: ${it.message}") }
} }
pfd = builder.establish() pfd = builder.establish() ?: throw IllegalStateException("VpnService.establish() 返回 null")
?: throw IllegalStateException("VpnService.establish() 返回 null")
activePfd = pfd activePfd = pfd
val fd = pfd.fd val fd = pfd.fd
VpnLog.info("网络代理已连接")
for ((index, step) in enabledSteps.withIndex()) { val stepsJson = JSONArray().apply {
if (stopRequested) { enabledSteps.forEach { step ->
VpnLog.warn("用户停止,终止后续步骤") put(JSONObject().apply {
break 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}" if (!VpnBridge.boot(fd, stepsJson, proxy)) {
val target = captureFile(filesDir, profile, step) throw IllegalStateException("tun core 启动失败")
target.parentFile?.mkdirs()
if (target.exists()) target.delete()
val capturedUrl = VpnBridge.runCapture(
fd,
step.hosts.joinToString(","),
step.path,
target.absolutePath,
proxy
)
if (capturedUrl == null) {
if (stopRequested) {
VpnLog.warn("$label 被用户停止")
break
} }
errorMsg = "$label 未抓到目标请求" VpnLog.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) VpnLog.error(errorMsg)
break
}
VpnLog.info("$label 抓到目标请求: $capturedUrl")
if (!target.exists() || target.length() == 0L) {
errorMsg = "$label 返回了 URL 但文件为空"
VpnLog.error(errorMsg)
break
}
lastFile = target
completed++
VpnLog.success("$label 抓取成功")
val result = CaptureResult(profile, step, capturedUrl, target)
postScope.launch {
try {
step.postAction(result)
} catch (c: CancellationException) {
VpnLog.warn("$label 后处理被取消: ${c.message}")
throw c
} catch (t: Throwable) {
VpnLog.error("$label 后处理失败: ${t.message}")
}
}
} }
} catch (t: Throwable) { } catch (t: Throwable) {
errorMsg = t.message ?: "抓包异常" errorMsg = t.message ?: "抓包异常"
VpnLog.error("抓包异常: ${t.message}") VpnLog.error("抓包异常: ${t.message}")
} finally { } finally {
VpnBridge.stepListener = null
VpnBridge.stopSession()
VpnBridge.shutdownSession()
closeActivePfd() closeActivePfd()
VpnLog.info("==== 抓包结束($completed/${enabledSteps.size} 步完成)====") VpnLog.info("==== 抓包结束(${stepResults.size}/${enabledSteps.size} 步完成)====")
if (errorMsg != null) { if (errorMsg != null) {
CaptureStateHolder.setError(errorMsg) CaptureStateHolder.setError(errorMsg)
} else if (completed == enabledSteps.size && lastFile != null) { } else if (stepResults.size == enabledSteps.size && lastCaptured != null) {
CaptureStateHolder.setCaptured(lastFile) CaptureStateHolder.setCaptured(lastCaptured!!)
} else { } else {
CaptureStateHolder.setIdle() CaptureStateHolder.setIdle()
} }
allDone = null
stopSelf() stopSelf()
} }
}, "capture-blocking-worker").apply { start() } }, "capture-blocking-worker").apply { start() }
} }
private fun handleStepResult(
profile: CaptureProfile,
step: CaptureProfileStep,
url: String?,
) {
val target = captureFile(filesDir, profile, step)
if (url == null || !target.exists() || target.length() == 0L) {
VpnLog.error("${step.name} 抓取失败")
return
}
stepResults[step.id] = url
lastCaptured = target
VpnLog.success("${step.name} 抓到目标请求")
val result = CaptureResult(profile, step, url, target)
postScope.launch {
try {
step.postAction(result)
} catch (c: CancellationException) {
VpnLog.warn("${step.name} 后处理被取消: ${c.message}")
throw c
} catch (t: Throwable) {
VpnLog.error("${step.name} 后处理失败: ${t.message}")
}
}
}
override fun onRevoke() { override fun onRevoke() {
stopRequested = true stopRequested = true
VpnLog.warn("VPN 权限被撤销") VpnLog.warn("VPN 权限被撤销")
closeActivePfd() VpnBridge.stopSession()
allDone?.countDown()
super.onRevoke() super.onRevoke()
} }
override fun onDestroy() { override fun onDestroy() {
if (captureThread?.isAlive != true) {
VpnBridge.stopSession()
VpnBridge.shutdownSession()
closeActivePfd() closeActivePfd()
}
captureThread = null captureThread = null
super.onDestroy() super.onDestroy()
} }
@@ -2,30 +2,14 @@ package net.bluemangoo.indpjsk.vpn
import android.util.Log import android.util.Log
/**
* Rust core_vpn 的 JNI 桥。
*
* 契约(由 Rust lib.rs 实现):
* runCapture(jint tunFd, jstring targetHosts, jstring path, jstring savePath, jstring proxy) -> String?
* - 阻塞直到抓包结束
* - tunFd 的所有权【仍在 Kotlin 侧】,Rust 内部用 BorrowedAsyncDevice 借用,不得 close()
* - targetHosts 是逗号分隔的域名白名单(一个步骤可能对应多个上游域名)
* - proxy 可为 null / 空串(直连),否则必须是 http(s)://host:port 形式,Rust 侧会再校验一次
* - 返回非 null = 抓到目标请求,值是命中的原始完整 URL(https://host/path?query),文件已写入 savePath
* - 返回 null = 失败 / 被 requestStop 打断 / 未匹配
*
* requestStop() -> void
* - 立即返回,仅设置原子停止标志,使阻塞中的 runCapture 尽快返回 false
*
* nativeLog(String)(Kotlin -> Rust 回调的落地端)
* - Rust 通过 JNIEnv.call_static_method(".../VpnBridge", "nativeLog", ...) 调用
* - 用于把 TLS 握手 / 匹配结果等关键日志写进 UI 日志框
*/
object VpnBridge { object VpnBridge {
private const val TAG = "VpnBridge" private const val TAG = "VpnBridge"
private var loaded = false private var loaded = false
@Volatile
var stepListener: ((String, String?) -> Unit)? = null
init { init {
loaded = try { loaded = try {
System.loadLibrary("core_vpn") System.loadLibrary("core_vpn")
@@ -39,18 +23,20 @@ object VpnBridge {
fun isAvailable(): Boolean = loaded fun isAvailable(): Boolean = loaded
@JvmStatic @JvmStatic
external fun runCapture( external fun boot(tunFd: Int, stepsJson: String, proxy: String?): Boolean
tunFd: Int,
targetHosts: String,
targetPath: String,
savePath: String,
proxy: String?
): String?
@JvmStatic @JvmStatic
external fun requestStop() external fun stopSession()
@JvmStatic
external fun shutdownSession()
@Suppress("unused")
@JvmStatic
fun nativeStepResult(stepId: String, url: String?) {
stepListener?.invoke(stepId, url)
}
/** 供 Rust JNI 回调写入日志框;Rust 侧通过 call_static_method 调用本方法 */
@Suppress("unused") @Suppress("unused")
@JvmStatic @JvmStatic
fun nativeLog(level: String, msg: String) { fun nativeLog(level: String, msg: String) {
@@ -15,12 +15,6 @@ data class LogEntry(
val message: String val message: String
) )
/**
* 全局日志容器:
* - Android 侧各处直接调用 info/warn/error/success
* - Rust 侧通过 JNI 静态方法 VpnBridge.nativeLog(String) 回调进来
* - UI 用 LazyColumn 消费 StateFlow,最多保留 MAX 条
*/
object VpnLog { object VpnLog {
private const val MAX = 800 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",
"rustls-native-certs", "rustls-native-certs",
"rustls-pemfile", "rustls-pemfile",
"serde",
"serde_json",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tokio-util", "tokio-util",
+2
View File
@@ -26,6 +26,8 @@ hyper-util = { version = "0.1", features = ["tokio", "server", "client"] }
http-body-util = "0.1" http-body-util = "0.1"
http = "1.5.0" http = "1.5.0"
qstring = "0.7.2" qstring = "0.7.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
url = "2" url = "2"
base64 = "0.23.1" base64 = "0.23.1"
+148 -64
View File
@@ -1,18 +1,17 @@
mod cert; mod cert;
mod mitm; mod mitm;
mod tun;
mod mut_qstring; mod mut_qstring;
mod tun;
use crate::mitm::{ProxyConfig, run_capture_async}; use crate::mitm::{ProxyConfig, StepSpec, Waiter, Waiters, run_session};
use jni::objects::{Global, JClass, JObject, JString}; use jni::objects::{Global, JClass, JObject, JString};
use jni::sys::{jint, jstring}; use jni::sys::{jboolean, jint};
use jni::errors::ThrowRuntimeExAndDefault;
use jni::{Env, EnvUnowned, JValue, JavaVM, jni_sig, jni_str}; use jni::{Env, EnvUnowned, JValue, JavaVM, jni_sig, jni_str};
use regex::Regex; use regex::Regex;
use std::sync::{OnceLock, RwLock}; use std::sync::{Arc, Mutex, OnceLock};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
static CANCLE_TOKEN: RwLock<Option<CancellationToken>> = RwLock::new(None); static SESSION: Mutex<Option<Session>> = Mutex::new(None);
static JVM: OnceLock<JavaVM> = OnceLock::new(); static JVM: OnceLock<JavaVM> = OnceLock::new();
static VPN_BRIDGE_CLASS: OnceLock<Global<JClass>> = OnceLock::new(); static VPN_BRIDGE_CLASS: OnceLock<Global<JClass>> = OnceLock::new();
@@ -21,7 +20,6 @@ fn init_jvm(env: &mut Env) {
return; return;
} }
if let Ok(vm) = env.get_java_vm() { 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")) { if let Ok(class) = env.find_class(jni_str!("net/bluemangoo/indpjsk/vpn/VpnBridge")) {
match env.new_global_ref(class) { match env.new_global_ref(class) {
Ok(g) => { Ok(g) => {
@@ -80,101 +78,187 @@ macro_rules! error {
($($arg:tt)*) => {$crate::log_to_kotlin("error", &format!($($arg)*))}; ($($arg:tt)*) => {$crate::log_to_kotlin("error", &format!($($arg)*))};
} }
struct Session {
cancel: CancellationToken,
waiters: Waiters,
done: std::sync::mpsc::Receiver<()>,
thread: Option<std::thread::JoinHandle<()>>,
}
pub fn notify_step_result(step_id: &str, url: Option<&str>) {
let Some(vm) = JVM.get() else { return };
let Some(class_ref) = VPN_BRIDGE_CLASS.get() else {
return;
};
let _ = vm.attach_current_thread(|env: &mut Env| -> Result<(), jni::errors::Error> {
let jid = env.new_string(step_id)?;
let jurl = match url {
Some(u) => Some(env.new_string(u)?),
None => None,
};
let jclass: &JClass = unsafe { &*(class_ref.as_obj() as *const JObject as *const JClass) };
let url_value = match &jurl {
Some(s) => JValue::Object(s),
None => JValue::Object(&JObject::null()),
};
env.call_static_method(
jclass,
jni_str!("nativeStepResult"),
jni_sig!("(Ljava/lang/String;Ljava/lang/String;)V"),
&[JValue::Object(&jid), url_value],
)?;
Ok(())
});
}
#[allow(non_snake_case)] #[allow(non_snake_case)]
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_runCapture( pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_boot(
mut env: EnvUnowned, mut env: EnvUnowned,
_class: JClass, _class: JClass,
tun_fd: jint, tun_fd: jint,
target_host: JString, steps_json: JString,
target_path: JString,
save_path: JString,
proxy: JString, proxy: JString,
) -> jstring { ) -> jboolean {
let _ = env.with_env(|env: &mut Env| -> Result<(), jni::errors::Error> { let _ = env.with_env(|env: &mut Env| -> Result<(), jni::errors::Error> {
init_jvm(env); init_jvm(env);
Ok(()) Ok(())
}); });
if SESSION.lock().unwrap().is_some() {
let host_arg: String = target_host.to_string(); error!("会话已在运行,忽略重复 boot");
let path: String = target_path.to_string(); return false;
let out: String = save_path.to_string();
let hosts: Vec<String> = host_arg
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if hosts.is_empty() {
error!("target hosts 为空");
return std::ptr::null_mut();
} }
let re = match Regex::new(&path) { let steps_raw: String = steps_json.to_string();
Ok(re) => re,
Err(e) => {
error!("Invalid regex pattern: {path} ({e})");
return std::ptr::null_mut();
}
};
let proxy_raw: String = if proxy.is_null() { let proxy_raw: String = if proxy.is_null() {
String::new() String::new()
} else { } else {
proxy.to_string() proxy.to_string()
}; };
let steps: Vec<StepSpec> = match serde_json::from_str(&steps_raw) {
Ok(v) => v,
Err(e) => {
error!("steps JSON 解析失败: {e}");
return false;
}
};
if steps.is_empty() {
error!("没有需要抓的步骤");
return false;
}
let proxy_cfg = match ProxyConfig::parse(&proxy_raw) { let proxy_cfg = match ProxyConfig::parse(&proxy_raw) {
Ok(cfg) => cfg, Ok(cfg) => cfg,
Err(e) => { Err(e) => {
error!("代理配置无效: {e}"); error!("代理配置无效: {e}");
return std::ptr::null_mut(); return false;
} }
}; };
if let Some(cfg) = &proxy_cfg { if let Some(cfg) = &proxy_cfg {
info!("upstream proxy: {}://{}:{}", if cfg.tls { "https" } else { "http" }, cfg.host, cfg.port); info!(
"upstream proxy: {}://{}:{}",
if cfg.tls { "https" } else { "http" },
cfg.host,
cfg.port
);
} }
let mut hosts: Vec<String> = Vec::new();
let mut waiter_list: Vec<Waiter> = Vec::new();
for step in steps {
let re = match Regex::new(&step.path) {
Ok(re) => re,
Err(e) => {
error!("步骤 {} 的正则不合法: {e}", step.id);
return false;
}
};
for h in step.hosts {
if !h.is_empty() && !hosts.iter().any(|x| x.eq_ignore_ascii_case(&h)) {
hosts.push(h);
}
}
waiter_list.push(Waiter {
id: step.id,
path: re,
save_path: step.save_path,
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 rt = tokio::runtime::Runtime::new().unwrap();
let cancel_token = CancellationToken::new(); if let Err(e) = rt.block_on(run_session(
*CANCLE_TOKEN.write().unwrap() = Some(cancel_token.clone()); tun_fd,
hosts,
let cancel_token_clone = cancel_token.clone(); proxy_cfg,
let result = rt.block_on(async move { thread_waiters,
run_capture_async(tun_fd, hosts, re, out, proxy_cfg, cancel_token_clone).await thread_cancel,
}); )) {
cancel_token.cancel(); error!("session error: {e:?}");
}
rt.shutdown_timeout(std::time::Duration::from_secs(2)); rt.shutdown_timeout(std::time::Duration::from_secs(2));
let _ = done_tx.send(());
}) {
Ok(t) => t,
Err(e) => {
error!("启动 session 线程失败: {e}");
return false;
}
};
let url = result.unwrap_or_else(|e| { *SESSION.lock().unwrap() = Some(Session {
error!("capture failed: {e:?}"); cancel,
None waiters,
done: done_rx,
thread: Some(thread),
}); });
let obj: JObject = env true
.with_env(|env: &mut Env| -> Result<JObject, jni::errors::Error> {
match &url {
Some(url) => {
let s = env.new_string(url)?;
Ok(unsafe { JObject::from_raw(env, s.as_raw()) })
}
None => Ok(JObject::null()),
}
})
.resolve::<ThrowRuntimeExAndDefault>();
obj.into_raw()
} }
#[allow(non_snake_case)] #[allow(non_snake_case)]
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_requestStop( pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_stopSession(
_env: EnvUnowned, _env: EnvUnowned,
_class: JClass, _class: JClass,
) { ) {
info!("Stopping capture..."); info!("session stop requested");
if let Some(token) = CANCLE_TOKEN.write().unwrap().take() { if let Some(session) = SESSION.lock().unwrap().as_ref() {
token.cancel() session.waiters.lock().unwrap().clear();
session.cancel.cancel();
} }
} }
#[allow(non_snake_case)]
#[unsafe(no_mangle)]
pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_shutdownSession(
_env: EnvUnowned,
_class: JClass,
) {
let session = SESSION.lock().unwrap().take();
let Some(mut session) = session else { return };
session.waiters.lock().unwrap().clear();
session.cancel.cancel();
let _ = session.done.recv();
if let Some(t) = session.thread.take() {
let _ = t.join();
}
info!("session shutdown complete");
}
+131 -88
View File
@@ -12,12 +12,11 @@ use rustls::RootCertStore;
use std::io; use std::io;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::os::fd::RawFd; use std::os::fd::RawFd;
use std::sync::{Arc, OnceLock}; use std::sync::{Arc, Mutex as StdMutex, OnceLock};
use std::time::Duration; use std::time::Duration;
use http::Uri; use http::Uri;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::UdpSocket; use tokio::net::UdpSocket;
use tokio::sync::Mutex;
use tokio::task::JoinSet; use tokio::task::JoinSet;
use tokio_rustls::StartHandshake; use tokio_rustls::StartHandshake;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
@@ -25,6 +24,15 @@ use tun_rs::BorrowedAsyncDevice;
static ROOT_STORE: OnceLock<Arc<RootCertStore>> = OnceLock::new(); 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 SNIFF_TIMEOUT: Duration = Duration::from_secs(5);
const TEARDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(3); const TEARDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(3);
const TEARDOWN_SETTLE: Duration = Duration::from_millis(50); const TEARDOWN_SETTLE: Duration = Duration::from_millis(50);
@@ -115,11 +123,9 @@ async fn handle_tls_connection<S>(
dst: SocketAddr, dst: SocketAddr,
upstream: Upstream, upstream: Upstream,
target_hosts: Vec<String>, target_hosts: Vec<String>,
target_path: Regex, waiters: Waiters,
save_path: String,
server_config: Arc<rustls::ServerConfig>, server_config: Arc<rustls::ServerConfig>,
cancel: CancellationToken, cancel: CancellationToken,
result_tx: Arc<Mutex<Option<tokio::sync::oneshot::Sender<String>>>>,
) -> anyhow::Result<()> ) -> anyhow::Result<()>
where where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static, S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
@@ -136,10 +142,17 @@ where
let Some((sni, accepted)) = sniffed else { let Some((sni, accepted)) = sniffed else {
io.rewind(); io.rewind();
io.stop_recording(); io.stop_recording();
// 非 TLS:拿不到 SNI,走代理时只能按 IP CONNECT
return passthrough_cancel_wrapped(io, dst, None, upstream, &cancel).await; return passthrough_cancel_wrapped(io, dst, None, upstream, &cancel).await;
}; };
// 崩溃上报之类的域名直接拒绝,不建立上游连接
if let Some(host) = sni.as_deref() {
if is_blocked_host(host) {
warn!("blocked host {host} ({dst:?}), connection rejected");
return Ok(());
}
}
let host_matched = sni let host_matched = sni
.as_deref() .as_deref()
.map(|s| target_hosts.iter().any(|h| s.eq_ignore_ascii_case(h))) .map(|s| target_hosts.iter().any(|h| s.eq_ignore_ascii_case(h)))
@@ -154,7 +167,6 @@ where
} }
return r; return r;
} }
info!("Encountered target host.");
io.stop_recording(); io.stop_recording();
let client_tls = StartHandshake::from_parts(accepted, io) let client_tls = StartHandshake::from_parts(accepted, io)
@@ -162,16 +174,13 @@ where
.await?; .await?;
let svc_host = sni.clone().unwrap_or_default(); let svc_host = sni.clone().unwrap_or_default();
let svc_upstream = upstream;
let svc = hyper::service::service_fn(move |req: hyper::Request<Incoming>| { let svc = hyper::service::service_fn(move |req: hyper::Request<Incoming>| {
let host = svc_host.clone(); let host = svc_host.clone();
let path = target_path.clone(); let waiters = waiters.clone();
let save = save_path.clone(); let upstream = upstream.clone();
let result_tx = result_tx.clone();
let upstream = svc_upstream.clone();
async move { async move {
Ok::<_, std::convert::Infallible>( Ok::<_, std::convert::Infallible>(
forward_request(req, dst, host, path, save, result_tx, upstream).await, forward_request(req, dst, host, waiters, upstream).await,
) )
} }
}); });
@@ -191,6 +200,7 @@ where
break; break;
} }
_ = cancel.cancelled(), if !graceful => { _ = cancel.cancelled(), if !graceful => {
info!("teardown: graceful shutdown of downstream connection");
conn.as_mut().graceful_shutdown(); conn.as_mut().graceful_shutdown();
graceful = true; graceful = true;
} }
@@ -218,38 +228,42 @@ where
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
async fn forward_request( async fn forward_request(
req: hyper::Request<Incoming>, mut req: hyper::Request<Incoming>,
dst: SocketAddr, dst: SocketAddr,
url_host: String, url_host: String,
target_path: Regex, waiters: Waiters,
save_path: String,
result_tx: Arc<Mutex<Option<tokio::sync::oneshot::Sender<String>>>>,
upstream: Upstream, upstream: Upstream,
) -> hyper::Response<Full<Bytes>> { ) -> hyper::Response<Full<Bytes>> {
match forward_request_inner(req, dst, &url_host, &target_path, &upstream).await { if let Some(rewritten) = force_full_mysekai(req.uri()) {
Ok((response, matched_body)) => { warn!("rewrite mysekai flag: {} -> {}", req.uri(), rewritten);
if let Some((url, body)) = matched_body { *req.uri_mut() = rewritten;
info!("Target matched! Downloading {} bytes", body.len());
let path = std::path::Path::new(&save_path);
if !path.is_absolute() {
error!("Failed to resolve path: {save_path}");
} }
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await; let uri = req.uri().clone();
} let url = build_url(&url_host, &uri);
match tokio::fs::write(path, &body).await { let path_and_query = uri
Ok(()) => { .path_and_query()
if let Some(tx) = result_tx.lock().await.take() { .map(|p| p.as_str().to_string())
let _ = tx.send(url); .unwrap_or_else(|| "/".to_string());
}
} let waiter = take_matching_waiter(&waiters, &path_and_query);
Err(e) => error!("write {save_path} failed: {e:?}"), if let Some(w) = &waiter {
info!("step '{}' matched", w.id);
} }
let want_body = waiter.is_some();
match forward_request_inner(req, dst, &url_host, upstream, want_body).await {
Ok((response, saved_body)) => {
if let (Some(w), Some(body)) = (waiter, saved_body) {
save_and_notify(w, url, body);
} }
response response
} }
Err(e) => { Err(e) => {
error!("forward to {dst} failed: {e:?}"); error!("forward to {dst} failed: {e:?}");
if let Some(w) = waiter {
crate::notify_step_result(&w.id, None);
}
hyper::Response::builder() hyper::Response::builder()
.status(hyper::StatusCode::BAD_GATEWAY) .status(hyper::StatusCode::BAD_GATEWAY)
.header("content-type", "text/plain; charset=utf-8") .header("content-type", "text/plain; charset=utf-8")
@@ -259,28 +273,39 @@ async fn forward_request(
} }
} }
fn save_and_notify(waiter: Waiter, url: String, body: Bytes) {
tokio::spawn(async move {
let path = std::path::Path::new(&waiter.save_path);
if !path.is_absolute() {
error!("save_path 不是绝对路径({})", waiter.save_path);
}
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
match tokio::fs::write(path, &body).await {
Ok(()) => {
info!("step '{}' saved ({} bytes)", waiter.id, body.len());
crate::notify_step_result(&waiter.id, Some(&url));
}
Err(e) => {
error!("write {} failed: {e:?}", waiter.save_path);
crate::notify_step_result(&waiter.id, None);
}
}
});
}
async fn forward_request_inner( async fn forward_request_inner(
req: hyper::Request<Incoming>, req: hyper::Request<Incoming>,
dst: SocketAddr, dst: SocketAddr,
url_host: &str, url_host: &str,
target_path: &Regex, upstream: Upstream,
upstream: &Upstream, want_body: bool,
) -> anyhow::Result<(hyper::Response<Full<Bytes>>, Option<(String, Bytes)>)> { ) -> anyhow::Result<(hyper::Response<Full<Bytes>>, Option<Bytes>)> {
let (mut parts, body) = req.into_parts(); let (mut parts, body) = req.into_parts();
if let Some(rewritten) = force_full_mysekai(&parts.uri) {
warn!("rewrite mysekai flag: {} -> {}", parts.uri, rewritten);
parts.uri = rewritten;
}
let parts_uri = parts.uri.clone();
let path_and_query = parts_uri
.path_and_query()
.map(|p| p.as_str().to_string())
.unwrap_or_else(|| "/".to_string());
let body_bytes = body.collect().await?.to_bytes(); let body_bytes = body.collect().await?.to_bytes();
// 拒绝长链接,反正抓到就跑。 // 拒绝长链接
for h in [ for h in [
"connection", "connection",
"keep-alive", "keep-alive",
@@ -297,7 +322,6 @@ async fn forward_request_inner(
let mut client_config = rustls::ClientConfig::builder() let mut client_config = rustls::ClientConfig::builder()
.with_root_certificates(root_store()) .with_root_certificates(root_store())
.with_no_client_auth(); .with_no_client_auth();
// 问就是有服务器不接受 H2
client_config.alpn_protocols = vec![b"http/1.1".to_vec()]; client_config.alpn_protocols = vec![b"http/1.1".to_vec()];
let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config)); let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config));
let server_name = rustls::pki_types::ServerName::try_from(url_host.to_string())?; let server_name = rustls::pki_types::ServerName::try_from(url_host.to_string())?;
@@ -329,17 +353,14 @@ async fn forward_request_inner(
res_parts.headers.remove(h); res_parts.headers.remove(h);
} }
let is_target = target_path.is_match(&path_and_query); let saved = if want_body {
let url = build_url(url_host, &parts_uri); Some(res_bytes.clone())
let save = if is_target {
Some((url, res_bytes.clone()))
} else { } else {
None None
}; };
Ok(( Ok((
hyper::Response::from_parts(res_parts, Full::new(res_bytes)), hyper::Response::from_parts(res_parts, Full::new(res_bytes)),
save, saved,
)) ))
} }
@@ -362,6 +383,40 @@ fn force_full_mysekai(uri: &Uri) -> Option<Uri> {
} }
#[derive(serde::Deserialize, Debug, Clone)]
pub struct StepSpec {
pub id: String,
#[serde(default)]
pub hosts: Vec<String>,
pub path: String,
#[serde(rename = "savePath")]
pub save_path: String,
/// 持续运行:命中后不摘除 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)] #[derive(Clone, Debug)]
pub struct ProxyConfig { pub struct ProxyConfig {
pub tls: bool, pub tls: bool,
@@ -500,14 +555,13 @@ async fn connect_via_proxy(
Ok(io) Ok(io)
} }
pub async fn run_capture_async( pub async fn run_session(
tun_fd: i32, tun_fd: i32,
target_hosts: Vec<String>, target_hosts: Vec<String>,
target_path: Regex,
save_path: String,
proxy: Option<ProxyConfig>, proxy: Option<ProxyConfig>,
cancel_token: CancellationToken, waiters: Waiters,
) -> anyhow::Result<Option<String>> { cancel: CancellationToken,
) -> anyhow::Result<()> {
let device = unsafe { BorrowedAsyncDevice::borrow_raw(tun_fd as RawFd)? }; let device = unsafe { BorrowedAsyncDevice::borrow_raw(tun_fd as RawFd)? };
let mut ipstack_config = ipstack::IpStackConfig::default(); let mut ipstack_config = ipstack::IpStackConfig::default();
@@ -520,19 +574,11 @@ pub async fn run_capture_async(
let tls_acceptor = build_tls_acceptor(); let tls_acceptor = build_tls_acceptor();
let upstream = Upstream::new(proxy); let upstream = Upstream::new(proxy);
let (result_tx, mut result_rx) = tokio::sync::oneshot::channel::<String>(); loop {
let result_tx = Arc::new(Mutex::new(Some(result_tx))); let token = cancel.clone();
let result: anyhow::Result<Option<String>> = loop {
let token = cancel_token.clone();
tokio::select! { tokio::select! {
_ = cancel_token.cancelled() => { _ = cancel.cancelled() => {
let final_result = result_rx.try_recv().ok(); break;
break Ok(final_result);
}
Ok(url) = &mut result_rx => {
cancel_token.cancel();
break Ok(Some(url));
} }
_ = bridge_tasks.join_next(), if !bridge_tasks.is_empty() => {} _ = bridge_tasks.join_next(), if !bridge_tasks.is_empty() => {}
@@ -541,7 +587,7 @@ pub async fn run_capture_async(
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
error!("ip_stack accept error (device might be dropped): {e:?}"); error!("ip_stack accept error (device might be dropped): {e:?}");
break Err(anyhow::Error::from(e)); break;
} }
}; };
@@ -551,7 +597,7 @@ pub async fn run_capture_async(
if dst.port() == 443 { if dst.port() == 443 {
// drop quic roughly // drop quic roughly
drop(udp); drop(udp);
} else{ } else {
bridge_tasks.spawn(async move { bridge_tasks.spawn(async move {
if let Err(e) = udp_passthrough(udp, dst, token).await { if let Err(e) = udp_passthrough(udp, dst, token).await {
error!("udp passthrough error: {e:?}"); error!("udp passthrough error: {e:?}");
@@ -564,7 +610,7 @@ pub async fn run_capture_async(
if dst.port() == 443 { if dst.port() == 443 {
let (app_io, mut tun_io) = tokio::io::duplex(256 * 1024); let (app_io, mut tun_io) = tokio::io::duplex(256 * 1024);
let pump_token = cancel_token.clone(); let pump_token = cancel.clone();
bridge_tasks.spawn(async move { bridge_tasks.spawn(async move {
tokio::select! { tokio::select! {
_ = pump_token.cancelled() => { _ = pump_token.cancelled() => {
@@ -585,11 +631,9 @@ pub async fn run_capture_async(
}); });
let hosts = target_hosts.clone(); let hosts = target_hosts.clone();
let path = target_path.clone();
let save = save_path.clone();
let server_config = tls_acceptor.clone(); let server_config = tls_acceptor.clone();
let token = cancel_token.clone(); let token = cancel.clone();
let result_tx = result_tx.clone(); let waiters = waiters.clone();
let upstream = upstream.clone(); let upstream = upstream.clone();
tokio::spawn(async move { tokio::spawn(async move {
@@ -598,18 +642,16 @@ pub async fn run_capture_async(
dst, dst,
upstream, upstream,
hosts, hosts,
path, waiters,
save,
server_config, server_config,
token, token,
result_tx
).await { ).await {
Ok(()) => { } Ok(()) => {}
Err(e) => error!("handle tls conn error: {e:?}"), Err(e) => error!("handle tls conn error: {e:?}"),
} }
}); });
}else{ } else {
let pump_token = cancel_token.clone(); let pump_token = cancel.clone();
let upstream = upstream.clone(); let upstream = upstream.clone();
bridge_tasks.spawn(async move { bridge_tasks.spawn(async move {
let _ = let _ =
@@ -622,7 +664,7 @@ pub async fn run_capture_async(
} }
} }
} }
}; }
let _ = tokio::time::timeout(TEARDOWN_GRACE, async { let _ = tokio::time::timeout(TEARDOWN_GRACE, async {
while bridge_tasks.join_next().await.is_some() {} while bridge_tasks.join_next().await.is_some() {}
@@ -631,7 +673,8 @@ pub async fn run_capture_async(
bridge_tasks.abort_all(); bridge_tasks.abort_all();
while bridge_tasks.join_next().await.is_some() {} while bridge_tasks.join_next().await.is_some() {}
result info!("session stopped");
Ok(())
} }
async fn tcp_passthrough<IO>( async fn tcp_passthrough<IO>(
+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;
}