2 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
13 changed files with 801 additions and 72 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 = 6
versionName = "1.3" versionName = "1.5"
} }
@@ -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>()
}
}
@@ -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)
+17
View File
@@ -24,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);
@@ -136,6 +145,14 @@ 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() {
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)))
+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;
}