5 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
18 changed files with 1033 additions and 246 deletions
+1 -2
View File
@@ -6,8 +6,7 @@ Rootless 开箱即用的 Project Sekai 用户数据抓包工具,支持上传
- 安装并打开应用 - 安装并打开应用
- 如果需要上传,在右上角菜单的「上传设置」里添加上传配置。 - 如果需要上传,在右上角菜单的「上传设置」里添加上传配置。
- 在右上角选择需要的配置 - 选择需要的配置,可以取消勾选不需要的抓包步骤
- 可以取消勾选不需要的抓包步骤
- 点击开始,如果跳出建立 VPN 的提示,请允许 - 点击开始,如果跳出建立 VPN 的提示,请允许
- 启动游戏,进入游戏触发需要抓的包,应用会自动抓取数据并自动结束 - 启动游戏,进入游戏触发需要抓的包,应用会自动抓取数据并自动结束
- 如果需要长时间反复抓同类型的包,在「代理设置」顶部勾选「持续运行」 - 如果需要长时间反复抓同类型的包,在「代理设置」顶部勾选「持续运行」
+2 -2
View File
@@ -18,8 +18,8 @@ android {
applicationId = "net.bluemangoo.indpjsk" applicationId = "net.bluemangoo.indpjsk"
minSdk = appMinSdk minSdk = appMinSdk
targetSdk = 37 targetSdk = 37
versionCode = 4 versionCode = 7
versionName = "1.3" versionName = "1.6"
} }
@@ -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,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>()
}
}
@@ -6,6 +6,25 @@ 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,7 +52,7 @@ 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)
@@ -108,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 }
} }
@@ -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,24 +29,28 @@ data class CaptureUiState(
class CaptureViewModel : ViewModel() { class CaptureViewModel : ViewModel() {
val uiState: StateFlow<CaptureUiState> = CaptureStateHolder.state private fun allProfiles(): List<CaptureProfile> = MixinProfiles.merged()
.map { s ->
CaptureUiState( val uiState: StateFlow<CaptureUiState> = combine(
profiles = PredefinedProfiles.all, CaptureStateHolder.state,
selectedProfile = s.profile, UploadStore.revision,
status = s.status, ) { s, _ ->
lastCapturedFile = s.lastCapturedFile, val profiles = allProfiles()
errorMessage = s.errorMessage CaptureUiState(
) profiles = profiles,
} selectedProfile = profiles.firstOrNull { it.id == s.profile.id } ?: s.profile,
.stateIn( status = s.status,
scope = viewModelScope, lastCapturedFile = s.lastCapturedFile,
started = SharingStarted.WhileSubscribed(5_000), errorMessage = s.errorMessage
initialValue = CaptureUiState(
profiles = PredefinedProfiles.all,
selectedProfile = PredefinedProfiles.all.firstOrNull()
)
) )
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = CaptureUiState(
profiles = allProfiles(),
selectedProfile = PredefinedProfiles.all.firstOrNull()
)
)
fun selectProfile(profile: CaptureProfile) { fun selectProfile(profile: CaptureProfile) {
CaptureStateHolder.selectProfile(profile) CaptureStateHolder.selectProfile(profile)
@@ -5,7 +5,10 @@ import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -27,9 +30,18 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.bluemangoo.indpjsk.R import net.bluemangoo.indpjsk.R
import net.bluemangoo.indpjsk.data.MixinProfiles
import net.bluemangoo.indpjsk.upload.* import net.bluemangoo.indpjsk.upload.*
import org.json.JSONObject import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.atomic.AtomicReference
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -42,6 +54,7 @@ fun UploadSettingsScreen(onBack: () -> Unit) {
if (target != null) { if (target != null) {
UploadEditScreen( UploadEditScreen(
initial = target.initial, initial = target.initial,
isNew = target.index == null,
takenIds = entries takenIds = entries
.filterIndexed { index, _ -> index != target.index } .filterIndexed { index, _ -> index != target.index }
.map { it.profile.id } .map { it.profile.id }
@@ -141,6 +154,17 @@ fun UploadSettingsScreen(onBack: () -> Unit) {
) )
} }
} }
}
@Composable
private fun ImportAction(text: String, onClick: () -> Unit) {
ListItem(
headlineContent = {
Text(text, style = MaterialTheme.typography.bodyMedium)
},
modifier = Modifier.clickable(onClick = onClick)
)
} }
private data class UploadEditTarget( private data class UploadEditTarget(
@@ -216,14 +240,11 @@ private fun UploadTableRow(
} }
} }
/**
* 添加 / 编辑共用的界面。
* 只有构造函数需要的字段需要用户填写;额外信息(extraInfo)只在下方展示。
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun UploadEditScreen( private fun UploadEditScreen(
initial: UploadEntry?, initial: UploadEntry?,
isNew: Boolean,
takenIds: Set<String>, takenIds: Set<String>,
onCancel: () -> Unit, onCancel: () -> Unit,
onSave: (UploadEntry) -> Unit, onSave: (UploadEntry) -> Unit,
@@ -246,7 +267,7 @@ private fun UploadEditScreen(
} }
var urlMode by remember(initial) { var urlMode by remember(initial) {
mutableStateOf( mutableStateOf(
when (val url = (original as? UploadProfileCustom)?.uploadUrl) { when ((original as? UploadProfileCustom)?.uploadUrl) {
is UploadUrlEach -> UploadStore.URL_MODE_EACH is UploadUrlEach -> UploadStore.URL_MODE_EACH
is UploadUrlGeneral -> UploadStore.URL_MODE_GENERAL is UploadUrlGeneral -> UploadStore.URL_MODE_GENERAL
null -> UploadStore.URL_MODE_GENERAL null -> UploadStore.URL_MODE_GENERAL
@@ -270,6 +291,13 @@ private fun UploadEditScreen(
(original as? UploadProfileCustom)?.customHeaders?.map { it.key to it.value } ?: emptyList() (original as? UploadProfileCustom)?.customHeaders?.map { it.key to it.value } ?: emptyList()
) )
} }
var mixinText by remember(initial) {
mutableStateOf(
(original as? UploadProfileCustom)?.mixin
?.let { UploadStore.encodeMixin(it).toString() }
?: ""
)
}
var typeExpanded by remember { mutableStateOf(false) } var typeExpanded by remember { mutableStateOf(false) }
var urlModeExpanded by remember { mutableStateOf(false) } var urlModeExpanded by remember { mutableStateOf(false) }
@@ -278,6 +306,91 @@ private fun UploadEditScreen(
var importText by remember { mutableStateOf("") } var importText by remember { mutableStateOf("") }
var importError by remember { mutableStateOf<String?>(null) } var importError by remember { mutableStateOf<String?>(null) }
// 从 JSON 导入:URL / 文件 / 直接打开 JSON 编辑器
var showImportMenu by remember { mutableStateOf(false) }
var showUrlDialog by remember { mutableStateOf(false) }
var urlText by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(false) }
var loadError by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val importJob = remember { AtomicReference<Job?>(null) }
val importConnection = remember { AtomicReference<HttpURLConnection?>(null) }
fun openJsonEditor(text: String) {
importText = text
importError = null
importDialog = true
}
fun finishLoad(text: String) {
if (text.isBlank()) {
loadError = "内容为空"
} else {
openJsonEditor(text)
}
}
fun cancelImport() {
importConnection.getAndSet(null)?.disconnect()
importJob.getAndSet(null)?.cancel()
loading = false
}
fun loadFromUrl() {
val url = urlText.trim()
if (url.isEmpty()) {
loadError = "请填写 URL"
return
}
loading = true
importJob.set(scope.launch {
try {
val result = withContext(Dispatchers.IO) {
runCatching {
val conn = URL(url).openConnection() as HttpURLConnection
importConnection.set(conn)
conn.instanceFollowRedirects = true
conn.connectTimeout = 15_000
conn.readTimeout = 20_000
conn.inputStream.bufferedReader().use { it.readText() }
}
}
result.fold(
onSuccess = { finishLoad(it) },
onFailure = { loadError = "从 URL 读取失败:${it.message}" },
)
} finally {
importConnection.getAndSet(null)?.disconnect()
loading = false
}
})
}
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) {
loading = true
importJob.set(scope.launch {
try {
val result = withContext(Dispatchers.IO) {
runCatching {
context.contentResolver.openInputStream(uri)
?.bufferedReader()
?.use { it.readText() }
?: error("无法打开文件")
}
}
result.fold(
onSuccess = { finishLoad(it) },
onFailure = { loadError = "读取文件失败:${it.message}" },
)
} finally {
loading = false
}
})
}
}
val trimmedId = id.trim() val trimmedId = id.trim()
val idError = when { val idError = when {
trimmedId.isEmpty() -> "ID 不能为空" trimmedId.isEmpty() -> "ID 不能为空"
@@ -291,7 +404,13 @@ private fun UploadEditScreen(
else -> null else -> null
} }
val canSave = idError == null && codeError == null
val parsedMixin = runCatching { UploadStore.parseMixin(mixinText) }
val mixinError = parsedMixin.exceptionOrNull()
?.let { "Mixin JSON 解析失败:${it.message}" }
?: parsedMixin.getOrNull()?.let { MixinProfiles.validate(it) }
val canSave = idError == null && codeError == null && mixinError == null
fun buildProfile(): UploadProfile = when (type) { fun buildProfile(): UploadProfile = when (type) {
UploadProfileType.HARUKI -> UploadProfileHaruki(trimmedId, uploadCode.trim()) UploadProfileType.HARUKI -> UploadProfileHaruki(trimmedId, uploadCode.trim())
@@ -312,6 +431,7 @@ private fun UploadEditScreen(
.map { it.first.trim() to it.second.trim() } .map { it.first.trim() to it.second.trim() }
.filter { it.first.isNotEmpty() } .filter { it.first.isNotEmpty() }
.toMap(), .toMap(),
mixin = parsedMixin.getOrNull(),
) )
} }
@@ -320,7 +440,7 @@ private fun UploadEditScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(if (initial == null) "添加上传配置" else "编辑上传配置") }, title = { Text(if (isNew) "添加上传配置" else "编辑上传配置") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onCancel) { IconButton(onClick = onCancel) {
Icon( Icon(
@@ -340,7 +460,6 @@ private fun UploadEditScreen(
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// ID 和类型放在同一行
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -481,13 +600,15 @@ private fun UploadEditScreen(
HeadersEditor(headers = headers, onChange = { headers = it }) HeadersEditor(headers = headers, onChange = { headers = it })
MixinEditor(
value = mixinText,
onValueChange = { mixinText = it.prependIndent().replace("\n", " ") },
error = mixinError,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton( OutlinedButton(
onClick = { onClick = { showImportMenu = true },
importText = ""
importError = null
importDialog = true
},
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.height(40.dp), .height(40.dp),
@@ -550,7 +671,6 @@ private fun UploadEditScreen(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 160.dp), .heightIn(min = 160.dp),
placeholder = { Text("{ ... }") },
textStyle = MaterialTheme.typography.bodySmall textStyle = MaterialTheme.typography.bodySmall
) )
importError?.let { importError?.let {
@@ -585,6 +705,9 @@ private fun UploadEditScreen(
} }
} }
headers = imported.customHeaders.map { it.key to it.value } headers = imported.customHeaders.map { it.key to it.value }
mixinText = imported.mixin
?.let { UploadStore.encodeMixin(it).toString() }
?: ""
importDialog = false importDialog = false
} }
@@ -599,6 +722,93 @@ private fun UploadEditScreen(
) )
} }
if (showImportMenu) {
AlertDialog(
onDismissRequest = { showImportMenu = false },
title = { Text("导入 JSON") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
ImportAction("从 URL 导入") {
showImportMenu = false
urlText = ""
showUrlDialog = true
}
ImportAction("选择文件") {
showImportMenu = false
filePicker.launch(
arrayOf("application/json", "text/plain", "application/octet-stream")
)
}
ImportAction("打开编辑器") {
showImportMenu = false
openJsonEditor("")
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = { showImportMenu = false }) { Text("取消") }
}
)
}
if (showUrlDialog) {
AlertDialog(
onDismissRequest = { showUrlDialog = false },
title = { Text("从 URL 导入") },
text = {
OutlinedTextField(
value = urlText,
onValueChange = { urlText = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("URL") },
placeholder = { Text("") },
textStyle = MaterialTheme.typography.bodySmall
)
},
confirmButton = {
TextButton(onClick = {
showUrlDialog = false
loadFromUrl()
}) { Text("导入") }
},
dismissButton = {
TextButton(onClick = { showUrlDialog = false }) { Text("取消") }
}
)
}
if (loading) {
AlertDialog(
onDismissRequest = { cancelImport() },
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false,
),
title = { Text("正在导入") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
},
confirmButton = {
TextButton(onClick = { cancelImport() }) { Text("取消") }
}
)
}
loadError?.let { message ->
AlertDialog(
onDismissRequest = { loadError = null },
title = { Text("加载失败") },
text = { Text(message) },
confirmButton = {
TextButton(onClick = { loadError = null }) { Text("确定") }
}
)
}
if (exportDialog) { if (exportDialog) {
val json = runCatching { val json = runCatching {
UploadStore.encodeProfile(buildProfile()).toString(2) UploadStore.encodeProfile(buildProfile()).toString(2)
@@ -702,6 +912,31 @@ private fun HeadersEditor(
} }
} }
@Composable
private fun MixinEditor(
value: String,
onValueChange: (String) -> Unit,
error: String?,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
FormRow("Mixin") {
CompactTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.weight(1f),
isError = error != null,
)
}
if (error != null) {
Text(
text = error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
@Composable @Composable
private fun FormRow( private fun FormRow(
label: String, label: String,
@@ -803,7 +1038,7 @@ private fun CompactDropdown(
} }
} }
private val helpLinkRegex = Regex("""\[([^\]]+)]\(([^)]+)\)""") private val helpLinkRegex = Regex("""\[([^]]+)]\(([^)]+)\)""")
@Composable @Composable
private fun HelpText(text: String) { private fun HelpText(text: String) {
@@ -1,5 +1,8 @@
package net.bluemangoo.indpjsk.upload 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.data.SettingsStore
import net.bluemangoo.indpjsk.vpn.VpnLog import net.bluemangoo.indpjsk.vpn.VpnLog
import org.json.JSONArray import org.json.JSONArray
@@ -14,6 +17,9 @@ object UploadStore {
private const val VERSION = 1 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_VERSION = "version"
private const val KEY_PROFILES = "profiles" private const val KEY_PROFILES = "profiles"
private const val KEY_ENABLED = "enabled" private const val KEY_ENABLED = "enabled"
@@ -25,6 +31,13 @@ object UploadStore {
private const val KEY_MODE = "mode" private const val KEY_MODE = "mode"
private const val KEY_URL = "url" private const val KEY_URL = "url"
private const val KEY_HEADERS = "headers" 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_SUITE = "suite"
private const val KEY_MYSEKAI = "mysekai" private const val KEY_MYSEKAI = "mysekai"
@@ -42,6 +55,7 @@ object UploadStore {
fun save(entries: List<UploadEntry>) { fun save(entries: List<UploadEntry>) {
SettingsStore.uploadProfilesJson = encode(entries) SettingsStore.uploadProfilesJson = encode(entries)
_revision.value += 1
} }
fun encode(entries: List<UploadEntry>): String { fun encode(entries: List<UploadEntry>): String {
@@ -67,7 +81,7 @@ object UploadStore {
val obj = item.optJSONObject(KEY_PROFILE) ?: continue val obj = item.optJSONObject(KEY_PROFILE) ?: continue
val profile = decodeProfile(obj) val profile = decodeProfile(obj)
if (profile == null) { if (profile == null) {
VpnLog.warn("跳过无法识别的上传配置: ${obj.optString(KEY_TYPE)}") VpnLog.warn("跳过无法识别的上传配置: ${obj.textOrNull(KEY_TYPE).orEmpty()}")
continue continue
} }
out += UploadEntry(item.optBoolean(KEY_ENABLED, false), profile) out += UploadEntry(item.optBoolean(KEY_ENABLED, false), profile)
@@ -85,26 +99,158 @@ object UploadStore {
is UploadProfileCustom -> { is UploadProfileCustom -> {
put(KEY_UPLOAD_URL, encodeUrl(profile.uploadUrl)) put(KEY_UPLOAD_URL, encodeUrl(profile.uploadUrl))
put(KEY_HEADERS, JSONObject(profile.customHeaders as Map<*, *>)) put(KEY_HEADERS, JSONObject(profile.customHeaders as Map<*, *>))
profile.mixin?.let { put(KEY_MIXIN, encodeMixin(it)) }
} }
} }
} }
fun decodeProfile(obj: JSONObject): UploadProfile? { fun decodeProfile(obj: JSONObject): UploadProfile? {
val id = obj.optString(KEY_ID) val id = obj.textOrNull(KEY_ID).orEmpty()
val type = UploadProfileType.entries.firstOrNull { it.name == obj.optString(KEY_TYPE) } val typeName = obj.textOrNull(KEY_TYPE).orEmpty()
val type = UploadProfileType.entries.firstOrNull { it.name == typeName }
?: return null ?: return null
val uploadCode = obj.textOrNull(KEY_UPLOAD_CODE).orEmpty()
return when (type) { return when (type) {
UploadProfileType.HARUKI -> UploadProfileHaruki(id, obj.optString(KEY_UPLOAD_CODE)) UploadProfileType.HARUKI -> UploadProfileHaruki(id, uploadCode)
UploadProfileType.REASONA -> UploadProfileReasona(id, obj.optString(KEY_UPLOAD_CODE)) UploadProfileType.REASONA -> UploadProfileReasona(id, uploadCode)
UploadProfileType._8823 -> UploadProfile8823(id) UploadProfileType._8823 -> UploadProfile8823(id)
UploadProfileType.CUSTOM -> UploadProfileCustom( UploadProfileType.CUSTOM -> UploadProfileCustom(
id = id, id = id,
uploadUrl = decodeUrl(obj.optJSONObject(KEY_UPLOAD_URL)), uploadUrl = decodeUrl(obj.optJSONObject(KEY_UPLOAD_URL)),
customHeaders = decodeHeaders(obj.optJSONObject(KEY_HEADERS)), 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) { private fun encodeUrl(url: UploadUrl): JSONObject = when (url) {
is UploadUrlGeneral -> JSONObject().apply { is UploadUrlGeneral -> JSONObject().apply {
put(KEY_MODE, URL_MODE_GENERAL) put(KEY_MODE, URL_MODE_GENERAL)
@@ -121,19 +267,24 @@ object UploadStore {
private fun decodeUrl(obj: JSONObject?): UploadUrl { private fun decodeUrl(obj: JSONObject?): UploadUrl {
if (obj == null) return UploadUrlGeneral("") if (obj == null) return UploadUrlGeneral("")
return when (obj.optString(KEY_MODE, URL_MODE_GENERAL)) { val mode = obj.textOrNull(KEY_MODE)?.trim()?.takeIf { it.isNotEmpty() } ?: URL_MODE_GENERAL
return when (mode) {
URL_MODE_EACH -> UploadUrlEach( URL_MODE_EACH -> UploadUrlEach(
suite = obj.optUrlOrNull(KEY_SUITE), suite = obj.optUrlOrNull(KEY_SUITE),
mysekai = obj.optUrlOrNull(KEY_MYSEKAI), mysekai = obj.optUrlOrNull(KEY_MYSEKAI),
birthday = obj.optUrlOrNull(KEY_BIRTHDAY), birthday = obj.optUrlOrNull(KEY_BIRTHDAY),
) )
else -> UploadUrlGeneral(obj.optString(KEY_URL)) else -> UploadUrlGeneral(
obj.textOrNull(KEY_URL)?.trim()?.takeIf {
it.isNotEmpty() && !it.equals("null", ignoreCase = true)
} ?: ""
)
} }
} }
private fun JSONObject.optUrlOrNull(key: String): String? = private fun JSONObject.optUrlOrNull(key: String): String? =
optString(key).trim().takeIf { it.isNotEmpty() } textOrNull(key)?.trim()?.takeIf { it.isNotEmpty() && !it.equals("null", ignoreCase = true) }
private fun decodeHeaders(obj: JSONObject?): Map<String, String> { private fun decodeHeaders(obj: JSONObject?): Map<String, String> {
if (obj == null) return emptyMap() if (obj == null) return emptyMap()
@@ -141,8 +292,14 @@ object UploadStore {
val keys = obj.keys() val keys = obj.keys()
while (keys.hasNext()) { while (keys.hasNext()) {
val key = keys.next() val key = keys.next()
map[key] = obj.optString(key) map[key] = obj.textOrNull(key) ?: continue
} }
return map 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)
} }
@@ -17,18 +17,29 @@ import kotlin.random.Random
object Uploader { object Uploader {
private const val CHUNK_SIZE = 1024 * 1024 private const val CHUNK_SIZE = 1024 * 1024
suspend fun tryUpload(result: CaptureResult) { suspend fun tryUpload(result: CaptureResult, onlyProfileIds: Set<String>? = null) {
val entries = UploadStore.load().filter { it.enabled } 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 if (entries.isEmpty()) return
VpnLog.info("${result.step.name} 开始上传(${entries.size} 个配置,串行)") VpnLog.info("${result.step.name} 开始上传(${entries.size} 个配置)")
for ((_, profile) in entries) { for ((_, profile) in entries) {
try { try {
val uploaded = uploadChunked(profile, result.url, result.file) val uploaded = uploadChunked(profile, result.url, result.file, result.step.uploadType)
if (uploaded) { if (uploaded) {
VpnLog.success("[${profile.id}] ${result.step.name} 上传完成") VpnLog.success("[${profile.id}] ${result.step.name} 上传完成")
} else {
VpnLog.info("[${profile.id}] ${result.step.name} 没有对应的上传地址,跳过")
} }
} catch (c: CancellationException) { } catch (c: CancellationException) {
throw c throw c
@@ -43,18 +54,20 @@ object Uploader {
profile: UploadProfile, profile: UploadProfile,
originalUrl: String, originalUrl: String,
file: File, file: File,
type: String? = null,
): Boolean = withContext(Dispatchers.IO) { ): Boolean = withContext(Dispatchers.IO) {
require(file.exists()) { "文件不存在: ${file.absolutePath}" } require(file.exists()) { "文件不存在: ${file.absolutePath}" }
val type = when (file.name) { val uploadType = type ?: when (file.name) {
"suite" -> "suite" "suite" -> "suite"
"mysekai" -> "mysekai" "mysekai" -> "mysekai"
"mysekai_birthday" -> "birthday" "mysekai_birthday" -> "birthday"
else -> { else -> null
VpnLog.error("${profile.id} 未知的文件类型 ${file.name}")
return@withContext false
}
} }
val url = profile.getUploadUrl(type)?.takeIf { it.isNotBlank() } if (uploadType == null) {
VpnLog.error("${profile.id} 未知的文件类型 ${file.name}")
return@withContext false
}
val url = profile.getUploadUrl(uploadType)?.takeIf { it.isNotBlank() }
?: return@withContext false ?: return@withContext false
val uploadId = randomUploadId() val uploadId = randomUploadId()
@@ -160,6 +173,54 @@ enum class UploadProfileType(val displayName: String) {
CUSTOM("自定义") 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 { sealed interface UploadProfile {
val id: String val id: String
val type: UploadProfileType val type: UploadProfileType
@@ -241,10 +302,10 @@ class UploadProfileReasona(override val id: String, val uploadCode: String) : Up
get() { get() {
val payload = parseJwt() ?: return emptyMap() val payload = parseJwt() ?: return emptyMap()
val map = mutableMapOf<String, String>() val map = mutableMapOf<String, String>()
if (payload.has("qqnum")) { if (payload.has("qqnum") && !payload.isNull("qqnum")) {
map["QQ 号"] = payload.optString("qqnum") map["QQ 号"] = payload.optString("qqnum")
} }
if (payload.has("sync_8823")) { if (payload.has("sync_8823") && !payload.isNull("sync_8823")) {
map["同步给 8823"] = payload.optBoolean("sync_8823").toString() map["同步给 8823"] = payload.optBoolean("sync_8823").toString()
} }
return map return map
@@ -274,8 +335,12 @@ class UploadProfile8823(override val id: String) : UploadProfile {
override val extraInfo: Map<String, String> = emptyMap() override val extraInfo: Map<String, String> = emptyMap()
} }
class UploadProfileCustom(override val id: String, val uploadUrl: UploadUrl, val customHeaders: Map<String, String>) : class UploadProfileCustom(
UploadProfile { 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 type: UploadProfileType = UploadProfileType.CUSTOM
override val helpText: String? = null override val helpText: String? = null
@@ -123,12 +123,11 @@ class CaptureVpnService : VpnService() {
val latch = CountDownLatch(1) val latch = CountDownLatch(1)
allDone = latch allDone = latch
// 持续运行:抓到后不自动停,并且同一类型的包可以反复抓(Rust 侧 waiter 不摘除)
val continueRun = SettingsStore.continueRun 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("持续运行: 开启(抓到后不会自动停止)") if (continueRun) VpnLog.info("持续运行: 开启(抓到后不会自动停止)")
VpnBridge.stepListener = { stepId, url -> VpnBridge.stepListener = { stepId, url ->
@@ -254,7 +253,6 @@ class CaptureVpnService : VpnService() {
VpnLog.success("${step.name} 抓到目标请求") VpnLog.success("${step.name} 抓到目标请求")
val result = CaptureResult(profile, step, url, target) val result = CaptureResult(profile, step, url, target)
VpnLog.info("${step.name} 立即执行后处理(不等其他步骤)")
postScope.launch { postScope.launch {
try { try {
step.postAction(result) step.postAction(result)
+1 -1
View File
@@ -13,7 +13,7 @@ tokio = { version = "1.47.5", features = ["full"] }
tokio-rustls = "0.26" tokio-rustls = "0.26"
rustls-pemfile = "2" rustls-pemfile = "2"
jni = "0.22.4" jni = "0.22.4"
rcgen = "0.14" rcgen = { version = "0.14", features = ["x509-parser"] }
anyhow = "1" anyhow = "1"
bytes = "1" bytes = "1"
tokio-util = "0.7.20" tokio-util = "0.7.20"
+4 -147
View File
@@ -1,156 +1,13 @@
use rcgen::{
CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair,
KeyUsagePurpose, SanType,
};
use std::{env, fs, path::PathBuf}; use std::{env, fs, path::PathBuf};
#[path = "src/cert.rs"]
const CA_KEY: &str = "-----BEGIN PRIVATE KEY----- mod cert;
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDAAAAAAAAAAAAAAAAAA use cert::*;
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAShZANiAAQTglHNUqyS
mMHIqtl3Mh3rl+cJvQtMoKylXcitUdz8nRWJoVl+OlEg4e/WMcY+GDXKyuKYaaYu
FjHoooGBq1ZhbcRdkYq8CfOrDmPPeSqk3O1zh743u6VpVJ8cArJw7Wc=
-----END PRIVATE KEY-----";
const CA_CHAIN: &str = "-----BEGIN PRIVATE KEY-----
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAShZANiAAQTglHNUqyS
mMHIqtl3Mh3rl+cJvQtMoKylXcitUdz8nRWJoVl+OlEg4e/WMcY+GDXKyuKYaaYu
FjHoooGBq1ZhbcRdkYq8CfOrDmPPeSqk3O1zh743u6VpVJ8cArJw7Wc=
-----END PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
MIIDaDCCAu6gAwIBAgIPKWkmwiejoVJjkrGUgqUEMAoGCCqGSM49BAMDMFYxCzAJ
BgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk
MSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM3
NDFaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk
aHJhIEluYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C
AQYFK4EEACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZ
fjpRIOHv1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+
N7ulaVSfHAKycO1no4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L
WUIgkFQwHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMB0GA1UdJQQWMBQG
CCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow
KAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB
BAGDjiEBAm4wCAYGZ4EMAQICMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY
aHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw
b3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNT1ZUTFNDQUczQy5wN2MwNwYDVR0f
BDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j
cmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E
AwMDaAAwZQIxAMSZt2xsPeumRdeSMSQ3CbzIZeQ/q7GDCWfeE1Pq+mhgA6gRM44c
NVF3xJ+wsxJk+wIwFJbaDMokD8Ei6eNOiA8yUMYvfzQyngmrhZQMdPCK1SewrR79
4rSb9vGqxZzelQth
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDdDCCAvmgAwIBAgIQAJYaPvsTWVdu0EAFtGhKWzAKBggqhkjOPQQDAzBrMQsw
CQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhy
YSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290
IENBIC0gRzMwHhcNMjQwNzExMTU0MzM2WhcNMzkwNzA5MTgzMDAwWjBWMQswCQYD
VQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEg
MB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUr
gQQAIgNiAARfD9w/q1cWxL6D9bMGH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8m
WO/RwXQy10DCSxT8vSIJClHKp+Ao4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFw
LKYf8EdDXAyjggF1MIIBcTAfBgNVHSMEGDAWgBR8XQKEE9TMipuBzhccLikenEhj
QjAdBgNVHQ4EFgQU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0lBBYwFAYIKwYB
BQUHAwEGCCsGAQUFBwMCMD0GA1UdIAQ2MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIB
FhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMHgGCCsGAQUFBwEBBGwwajAi
BggrBgEFBQcwAYYWaHR0cDovL29jc3AuZW1zaWduLmNvbTBEBggrBgEFBQcwAoY4
aHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbS9jZXJ0cy9lbVNpZ25FQ0NSb290
Q0FHMy5jcnQwMwYDVR0fBCwwKjAooCagJIYiaHR0cDovL2NybC5lbXNpZ24uY29t
P1Jvb3RDQUczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIB
ATAKBggqhkjOPQQDAwNpADBmAjEAt9CIpRGp46QHQYDUBnLKw+vvK0/h9vmilEAg
TroUl/anH4x2rXHqiCtBTBfgKDegAjEA8WpG1LTZdAIL0FSYqxXeREaeTSxEofM0
1jJH3jGvCXC79LU9UPJ2o3TU+UIj2gMV
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDzzCCAregAwIBAgIRAI1aSBGTnvRH13ERzdmEmukwDQYJKoZIhvcNAQELBQAw
ezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G
A1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV
BAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y
ODEyMzEyMzU5NTlaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu
YzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EE
ACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZfjpRIOHv
1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+N7ulaVSf
HAKycO1no4IBNTCCATEwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQw
HQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMA4GA1UdDwEB/wQEAwIBBjAS
BgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
AjAxBgNVHSAEKjAoMAgGBmeBDAECAjANBgsrBgEEAYOOIQEAATANBgsrBgEEAYOO
IQECbjBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9B
QUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYB
BQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOC
AQEAHlbhZpjhkPEAQVhPHRtBDaHqiisVD20pQ1TUeSx3l62Qe7NQ0b7vWQy7DN0i
KOu7BGpP5EnIjvXbz9FhpfPJ4FhKEt3tQ51rqXz2oD8vRaryvExWPobDUz3j7HN6
OzBsVMIe3PD7AAkpky0x8zMSi/ep5R9OMzybmNptbtppyuB5ytgq+h7lQdRIP+CQ
TaJX3qTgfMqzntYre5GljmEB5hU2/rSn8zoSU2APkJidlZFpghtgbD4I2VRHZvCw
FFr/SrFM9pwrgbJNZ1MtyTSVgI5NlNb0aMfLR+/r6jJt3ROeY2yh2QQ2dx+Yqgdb
uL5ChFWOQU+ErSDnUq7yr+pjBQ==
-----END CERTIFICATE-----";
/// 证书里要覆盖的域名。抓包目标 host 必须在这里面,否则 App 会报证书不受信任。
const SAN: &[&str] = &[
// 国服
"*.dailygn.com",
// 日服
"production-game-api.sekai.colorfulpalette.org",
"*.sekai.colorfulpalette.org",
// 国际服
"n-production-game-api.sekai-en.com",
"*.sekai-en.com",
// 台服 / 韩服(cdn 和 https 两个域都会出现)
"*.bytedgame.com",
"mk-zian-obt-cdn.bytedgame.com",
"mk-zian-obt-https.bytedgame.com",
"mkkorea-obt-prod01-cdn.bytedgame.com",
"mkkorea-obt-prod01-https.bytedgame.com",
];
fn main() { fn main() {
println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=build.rs");
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let ca_key = KeyPair::from_pem(CA_KEY).expect("parse CA key failed"); let (fullchain_pem, leaf_key_pem) = sign(SAN.iter());
let first_ca_pem = first_pem_certificate(CA_CHAIN);
let issuer = rcgen::Issuer::from_ca_cert_pem(&first_ca_pem, ca_key)
.expect("build Issuer from CA cert + key failed");
let mut leaf_params = CertificateParams::default();
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, SAN[0]);
leaf_params.distinguished_name = dn;
leaf_params.subject_alt_names = SAN
.iter()
.map(|d| SanType::DnsName((*d).try_into().unwrap()))
.collect();
leaf_params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
leaf_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
leaf_params.is_ca = IsCa::NoCa;
// AKI 必须和签发者证书里的 SKI 一致,rcgen 的 Issuer 会自动从 CA 证书里取
leaf_params.use_authority_key_identifier_extension = true;
// 有效期落在 CA 的有效期内(2025-04-30 ~ 2040-04-28)
leaf_params.not_before = rcgen::date_time_ymd(2025, 5, 1);
leaf_params.not_after = rcgen::date_time_ymd(2038, 1, 1);
let leaf_key = KeyPair::generate().expect("generate leaf key failed");
let leaf_cert = leaf_params
.signed_by(&leaf_key, &issuer)
.expect("sign leaf cert failed");
// leaf + 完整 CA 链(两个 G3C 交叉签名都带上,让验证器自己选路径)
let fullchain_pem = format!("{}{}", leaf_cert.pem(), CA_CHAIN);
fs::write(out_dir.join("fullchain.pem"), fullchain_pem).unwrap(); 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(); 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 rcgen::{
use std::io::BufReader; CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair,
use std::sync::Arc; KeyUsagePurpose, SanType,
};
pub fn build_tls_acceptor() -> Arc<rustls::ServerConfig> { const CA_KEY: &str = "-----BEGIN PRIVATE KEY-----
let fullchain = include_bytes!(concat!(env!("OUT_DIR"), "/fullchain.pem")); MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDAAAAAAAAAAAAAAAAAA
let leaf_key = include_bytes!(concat!(env!("OUT_DIR"), "/leaf.key")); AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAShZANiAAQTglHNUqyS
mMHIqtl3Mh3rl+cJvQtMoKylXcitUdz8nRWJoVl+OlEg4e/WMcY+GDXKyuKYaaYu
FjHoooGBq1ZhbcRdkYq8CfOrDmPPeSqk3O1zh743u6VpVJ8cArJw7Wc=
-----END PRIVATE KEY-----";
let cert_chain = certs(&mut BufReader::new(&fullchain[..])) const CA_CHAIN: &str = "-----BEGIN PRIVATE KEY-----
.collect::<Result<Vec<_>, _>>() MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDAAAAAAAAAAAAAAAAAA
.unwrap(); AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAShZANiAAQTglHNUqyS
let key = private_key(&mut BufReader::new(&leaf_key[..])) mMHIqtl3Mh3rl+cJvQtMoKylXcitUdz8nRWJoVl+OlEg4e/WMcY+GDXKyuKYaaYu
.unwrap() FjHoooGBq1ZhbcRdkYq8CfOrDmPPeSqk3O1zh743u6VpVJ8cArJw7Wc=
.unwrap(); -----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() pub const SAN: &[&str] = &[
.with_no_client_auth() // 国服
.with_single_cert(cert_chain, key) "*.dailygn.com",
.unwrap(); // 日服
"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()]; #[allow(unused)] // caused in re-build in build.rs
pub fn check_cert_included<'a>(target_hosts: impl Iterator<Item = &'a str>) -> bool {
Arc::new(config) 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");
}
} }
+1
View File
@@ -2,6 +2,7 @@ mod cert;
mod mitm; mod mitm;
mod mut_qstring; mod mut_qstring;
mod tun; mod tun;
mod acceptor;
use crate::mitm::{ProxyConfig, StepSpec, Waiter, Waiters, run_session}; use crate::mitm::{ProxyConfig, StepSpec, Waiter, Waiters, run_session};
use jni::objects::{Global, JClass, JObject, JString}; use jni::objects::{Global, JClass, JObject, JString};
+31 -7
View File
@@ -1,8 +1,8 @@
use crate::cert::build_tls_acceptor;
use crate::mut_qstring::MutQString; use crate::mut_qstring::MutQString;
use crate::tun::{BorrowedTunIo, RewindStream}; use crate::tun::{BorrowedTunIo, RewindStream};
use crate::{error, info, warn}; use crate::{error, info, warn};
use bytes::Bytes; use bytes::Bytes;
use http::Uri;
use http_body_util::{BodyExt, Full}; use http_body_util::{BodyExt, Full};
use hyper::body::Incoming; use hyper::body::Incoming;
use hyper_util::rt::TokioIo; use hyper_util::rt::TokioIo;
@@ -14,16 +14,25 @@ use std::net::SocketAddr;
use std::os::fd::RawFd; use std::os::fd::RawFd;
use std::sync::{Arc, Mutex as StdMutex, OnceLock}; use std::sync::{Arc, Mutex as StdMutex, OnceLock};
use std::time::Duration; use std::time::Duration;
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::task::JoinSet; use tokio::task::JoinSet;
use tokio_rustls::StartHandshake; use tokio_rustls::StartHandshake;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tun_rs::BorrowedAsyncDevice; use tun_rs::BorrowedAsyncDevice;
use crate::acceptor::build_tls_acceptor;
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);
@@ -136,6 +145,13 @@ where
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()
&& 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)))
@@ -365,7 +381,6 @@ fn force_full_mysekai(uri: &Uri) -> Option<Uri> {
Uri::try_from(format!("{}?{}", uri.path(), query).as_str()).ok() Uri::try_from(format!("{}?{}", uri.path(), query).as_str()).ok()
} }
#[derive(serde::Deserialize, Debug, Clone)] #[derive(serde::Deserialize, Debug, Clone)]
pub struct StepSpec { pub struct StepSpec {
pub id: String, pub id: String,
@@ -433,9 +448,16 @@ impl ProxyConfig {
None None
} else { } else {
let pass = url.password().unwrap_or(""); 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,
}))
} }
} }
@@ -456,7 +478,9 @@ struct Upstream {
impl Upstream { impl Upstream {
fn new(proxy: Option<ProxyConfig>) -> Self { 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> { async fn connect(&self, dst: SocketAddr, host: Option<&str>) -> anyhow::Result<UpstreamIo> {
@@ -554,7 +578,7 @@ pub async fn run_session(
let mut ip_stack = ipstack::IpStack::new(ipstack_config, tun_io); let mut ip_stack = ipstack::IpStack::new(ipstack_config, tun_io);
let mut bridge_tasks = JoinSet::new(); 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 upstream = Upstream::new(proxy);
loop { loop {
+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;
}