细化+上游代理+1.1

This commit is contained in:
2026-10-25 19:01:45 +08:00
parent 5ec7815a13
commit 070020a296
21 changed files with 1429 additions and 158 deletions
+28 -7
View File
@@ -6,7 +6,7 @@ plugins {
id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.compose")
} }
val minSdk = 28 val appMinSdk = 28
android { android {
namespace = "net.bluemangoo.indpjsk" namespace = "net.bluemangoo.indpjsk"
@@ -16,7 +16,7 @@ android {
defaultConfig { defaultConfig {
applicationId = "net.bluemangoo.indpjsk" applicationId = "net.bluemangoo.indpjsk"
minSdk = minSdk minSdk = appMinSdk
targetSdk = 37 targetSdk = 37
versionCode = 1 versionCode = 1
versionName = "1.1" versionName = "1.1"
@@ -35,7 +35,29 @@ android {
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
sourceSets.getByName("main").assets.directories.add(
layout.buildDirectory.dir("generated/readme").get().asFile.absolutePath
)
}
val copyReadmeToAssets = tasks.register<Copy>("copyReadmeToAssets") {
group = "build"
description = "Pack README.md"
from(rootProject.file("README.md"))
into(layout.buildDirectory.dir("generated/readme").get().asFile)
rename { "README.md" }
inputs.file(rootProject.file("README.md")).withPropertyName("readme")
outputs.dir(layout.buildDirectory.dir("generated/readme")).withPropertyName("readmeAssets")
}
tasks.named("preBuild") {
dependsOn(copyReadmeToAssets)
}
tasks.matching { it.name.startsWith("merge") && it.name.contains("Assets") }.configureEach {
dependsOn(copyReadmeToAssets)
} }
dependencies { dependencies {
@@ -87,7 +109,6 @@ fun resolveNdkDirectory(project: Project): File {
throw GradleException("Failed to find any installed NDK versions in ${ndkParentDir.absolutePath}") throw GradleException("Failed to find any installed NDK versions in ${ndkParentDir.absolutePath}")
} }
// 取最新安装的版本
return installedVersions.last() return installedVersions.last()
} }
@@ -117,19 +138,19 @@ val cargoBuildVpnCore = tasks.register<Exec>("cargoBuildVpnCore") {
val execExt = if (OperatingSystem.current().isWindows) ".exe" else "" val execExt = if (OperatingSystem.current().isWindows) ".exe" else ""
environment("NDK_HOME", ndkHomePath) environment("NDK_HOME", ndkHomePath)
environment("PATH", "$ndkHomePath:${System.getenv("PATH")}") environment("PATH", "${ndkBin.absolutePath}${File.pathSeparator}${System.getenv("PATH")}")
environment( environment(
"CC_aarch64-linux-android", "CC_aarch64-linux-android",
ndkBin.resolve("aarch64-linux-android$minSdk-clang$scriptExt").absolutePath ndkBin.resolve("aarch64-linux-android$appMinSdk-clang$scriptExt").absolutePath
) )
environment( environment(
"CXX_aarch64-linux-android", "CXX_aarch64-linux-android",
ndkBin.resolve("aarch64-linux-android$minSdk-clang++$scriptExt").absolutePath ndkBin.resolve("aarch64-linux-android$appMinSdk-clang++$scriptExt").absolutePath
) )
environment("AR_aarch64-linux-android", ndkBin.resolve("llvm-ar$execExt").absolutePath) environment("AR_aarch64-linux-android", ndkBin.resolve("llvm-ar$execExt").absolutePath)
environment( environment(
"CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER", "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER",
ndkBin.resolve("aarch64-linux-android$minSdk-clang$scriptExt").absolutePath ndkBin.resolve("aarch64-linux-android$appMinSdk-clang$scriptExt").absolutePath
) )
} catch (e: Exception) { } catch (e: Exception) {
logger.warn("[cargoBuildVpnCore] resolveNdkDirectory failed: ${e.message}") logger.warn("[cargoBuildVpnCore] resolveNdkDirectory failed: ${e.message}")
+3
View File
@@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -6,15 +6,30 @@ import android.net.Uri
import android.net.VpnService import android.net.VpnService
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import net.bluemangoo.indpjsk.data.PredefinedProfiles
import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.state.CaptureStateHolder
import net.bluemangoo.indpjsk.ui.AboutScreen
import net.bluemangoo.indpjsk.ui.AppSelectionScreen
import net.bluemangoo.indpjsk.ui.CaptureScreen import net.bluemangoo.indpjsk.ui.CaptureScreen
import net.bluemangoo.indpjsk.ui.CaptureViewModel import net.bluemangoo.indpjsk.ui.CaptureViewModel
import net.bluemangoo.indpjsk.ui.CapturedFilesScreen
import net.bluemangoo.indpjsk.ui.ProxySettingsScreen
import net.bluemangoo.indpjsk.ui.theme.IndPjskTheme import net.bluemangoo.indpjsk.ui.theme.IndPjskTheme
import java.io.File import java.io.File
enum class AppScreen { MAIN, PROXY, APP_SELECTION, FILES, ABOUT }
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val viewModel: CaptureViewModel by viewModels() private val viewModel: CaptureViewModel by viewModels()
@@ -28,15 +43,73 @@ class MainActivity : ComponentActivity() {
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
SettingsStore.loadSelectedProfileId(this)
?.let { PredefinedProfiles.byId(it) }
?.let { CaptureStateHolder.selectProfile(it) }
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
IndPjskTheme { IndPjskTheme {
CaptureScreen( var screen by rememberSaveable { mutableStateOf(AppScreen.MAIN) }
viewModel = viewModel, var bypassPackages by remember {
onStartCapture = { requestVpnAndStart() }, mutableStateOf(SettingsStore.loadBypassPackages(this))
onStopCapture = { viewModel.stopCapture(this) }, }
onOpenFile = { openCapturedFile(it) } val readme = remember {
) runCatching {
assets.open("README.md").bufferedReader().use { it.readText() }
}.getOrDefault("(README 未打包)")
}
BackHandler(enabled = screen != AppScreen.MAIN) {
screen = when (screen) {
AppScreen.APP_SELECTION -> AppScreen.PROXY
else -> AppScreen.MAIN
}
}
when (screen) {
AppScreen.MAIN -> CaptureScreen(
viewModel = viewModel,
onStartCapture = { requestVpnAndStart() },
onStopCapture = { viewModel.stopCapture(this) },
onOpenProxySettings = { screen = AppScreen.PROXY },
onOpenFiles = { screen = AppScreen.FILES },
onOpenAbout = { screen = AppScreen.ABOUT },
)
AppScreen.PROXY -> ProxySettingsScreen(
bypassPackages = bypassPackages,
onBypassChange = { newSet ->
bypassPackages = newSet
SettingsStore.saveBypassPackages(this, newSet)
},
onSelectApps = { screen = AppScreen.APP_SELECTION },
onBack = { screen = AppScreen.MAIN },
)
AppScreen.APP_SELECTION -> AppSelectionScreen(
selected = bypassPackages,
onToggle = { entry ->
bypassPackages = if (entry.packageName in bypassPackages) {
bypassPackages - entry.packageName
} else {
bypassPackages + entry.packageName
}
SettingsStore.saveBypassPackages(this, bypassPackages)
},
onBack = { screen = AppScreen.PROXY },
)
AppScreen.FILES -> CapturedFilesScreen(
onOpenFile = { openCapturedFile(it) },
onBack = { screen = AppScreen.MAIN },
)
AppScreen.ABOUT -> AboutScreen(
readme = readme,
onBack = { screen = AppScreen.MAIN },
)
}
} }
} }
} }
@@ -80,4 +153,4 @@ class MainActivity : ComponentActivity() {
"html", "htm" -> "text/html" "html", "htm" -> "text/html"
else -> "application/octet-stream" else -> "application/octet-stream"
} }
} }
@@ -0,0 +1,24 @@
package net.bluemangoo.indpjsk.data
import android.content.Context
import java.io.File
object CaptureFiles {
fun dir(context: Context): File = File(context.filesDir, "captures")
fun list(context: Context): List<File> {
val root = dir(context)
if (!root.exists()) return emptyList()
return root.walkTopDown()
.filter { it.isFile }
.sortedByDescending { it.lastModified() }
.toList()
}
fun clear(context: Context) {
val root = dir(context)
root.deleteRecursively()
root.mkdirs()
}
}
@@ -0,0 +1,46 @@
package net.bluemangoo.indpjsk.data
import android.content.Context
import android.content.pm.ApplicationInfo
import android.graphics.drawable.Drawable
data class AppEntry(
val packageName: String,
val label: String,
val icon: Drawable,
)
object InstalledApps {
fun list(context: Context): List<AppEntry> {
val pm = context.packageManager
return pm.getInstalledApplications(0)
.asSequence()
.filter { info ->
(info.flags and ApplicationInfo.FLAG_SYSTEM) == 0 ||
(info.flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) != 0
}
.mapNotNull { info ->
runCatching {
AppEntry(
packageName = info.packageName,
label = pm.getApplicationLabel(info).toString(),
icon = pm.getApplicationIcon(info),
)
}.getOrNull()
}
.distinctBy { it.packageName }
.sortedBy { it.label.lowercase() }
.toList()
}
fun get(context: Context, packageName: String): AppEntry? = runCatching {
val pm = context.packageManager
val info = pm.getApplicationInfo(packageName, 0)
AppEntry(
packageName = packageName,
label = pm.getApplicationLabel(info).toString(),
icon = pm.getApplicationIcon(info),
)
}.getOrNull()
}
@@ -3,21 +3,47 @@ package net.bluemangoo.indpjsk.data
import android.content.Context import android.content.Context
import androidx.core.content.edit import androidx.core.content.edit
/** 一些零碎的全局设置,暂时只有上传码 */ /** 全局配置持久化:上传码、上游代理、忽略的应用、选中的配置 */
object SettingsStore { object SettingsStore {
private const val PREFS = "settings" private const val PREFS = "settings"
private const val KEY_UPLOAD_CODE = "upload_code" private const val KEY_UPLOAD_CODE = "upload_code"
private const val KEY_PROXY = "upstream_proxy"
private const val KEY_BYPASS = "bypass_packages"
private const val KEY_PROFILE = "selected_profile"
fun loadUploadCode(context: Context): String = private fun prefs(context: Context) =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_UPLOAD_CODE, "")
.orEmpty() // ---- 上传码 ----
fun loadUploadCode(context: Context): String =
prefs(context).getString(KEY_UPLOAD_CODE, "").orEmpty()
fun saveUploadCode(context: Context, code: String) { fun saveUploadCode(context: Context, code: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) prefs(context).edit { putString(KEY_UPLOAD_CODE, code) }
.edit { }
putString(KEY_UPLOAD_CODE, code)
} // ---- 上游代理(空 = 直连) ----
fun loadProxy(context: Context): String =
prefs(context).getString(KEY_PROXY, "").orEmpty()
fun saveProxy(context: Context, proxy: String) {
prefs(context).edit { putString(KEY_PROXY, proxy.trim()) }
}
// ---- 不走 VPN 的应用(包名) ----
fun loadBypassPackages(context: Context): Set<String> =
prefs(context).getStringSet(KEY_BYPASS, emptySet())?.toSet() ?: emptySet()
fun saveBypassPackages(context: Context, packages: Set<String>) {
prefs(context).edit { putStringSet(KEY_BYPASS, packages) }
}
// ---- 当前选中的配置 ----
fun loadSelectedProfileId(context: Context): String? =
prefs(context).getString(KEY_PROFILE, null)
fun saveSelectedProfileId(context: Context, id: String) {
prefs(context).edit { putString(KEY_PROFILE, id) }
} }
} }
@@ -0,0 +1,67 @@
package net.bluemangoo.indpjsk.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import net.bluemangoo.indpjsk.BuildConfig
import net.bluemangoo.indpjsk.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(
readme: String,
onBack: () -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("关于") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(R.drawable.arrow_back_24px),
contentDescription = "返回"
)
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Text(
text = "Version ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
style = MaterialTheme.typography.titleMedium
)
Spacer(Modifier.height(8.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
Text(
text = readme,
style = MaterialTheme.typography.bodySmall,
softWrap = true
)
}
}
}
@@ -0,0 +1,25 @@
package net.bluemangoo.indpjsk.ui
import android.graphics.drawable.Drawable
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
@Composable
fun AppIcon(icon: Drawable, label: String?, size: Dp = 40.dp) {
val image = remember(icon) {
runCatching { icon.toBitmap(96, 96) }.getOrNull()?.asImageBitmap()
}
if (image != null) {
Image(bitmap = image, contentDescription = label, modifier = Modifier.size(size))
} else {
Box(modifier = Modifier.size(size))
}
}
@@ -0,0 +1,168 @@
package net.bluemangoo.indpjsk.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.bluemangoo.indpjsk.R
import net.bluemangoo.indpjsk.data.AppEntry
import net.bluemangoo.indpjsk.data.InstalledApps
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppSelectionScreen(
selected: Set<String>,
onToggle: (AppEntry) -> Unit,
onBack: () -> Unit,
) {
val context = LocalContext.current
var allApps by remember { mutableStateOf<List<AppEntry>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var query by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
val list = withContext(Dispatchers.IO) { InstalledApps.list(context) }
allApps = list
loading = false
}
val filtered = remember(query, allApps) {
if (query.isBlank()) {
allApps
} else {
allApps.filter {
it.label.contains(query, ignoreCase = true) ||
it.packageName.contains(query, ignoreCase = true)
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("选择忽略的应用") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(R.drawable.arrow_back_24px),
contentDescription = "返回"
)
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text("搜索应用名称或包名…") },
singleLine = true
)
if (loading) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else if (filtered.isEmpty()) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
"没有找到相关的应用",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) {
items(filtered, key = { it.packageName }) { app ->
val checked = app.packageName in selected
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onToggle(app) }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppIcon(icon = app.icon, label = app.label)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = app.label,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = app.packageName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Checkbox(
checked = checked,
onCheckedChange = { onToggle(app) }
)
}
}
}
}
}
}
}
@@ -1,43 +1,13 @@
package net.bluemangoo.indpjsk.ui package net.bluemangoo.indpjsk.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.*
import androidx.compose.material3.ButtonDefaults import androidx.compose.runtime.*
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -58,7 +28,6 @@ import net.bluemangoo.indpjsk.state.CaptureStatus
import net.bluemangoo.indpjsk.vpn.LogEntry import net.bluemangoo.indpjsk.vpn.LogEntry
import net.bluemangoo.indpjsk.vpn.LogLevel import net.bluemangoo.indpjsk.vpn.LogLevel
import net.bluemangoo.indpjsk.vpn.VpnLog import net.bluemangoo.indpjsk.vpn.VpnLog
import java.io.File
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -66,15 +35,18 @@ fun CaptureScreen(
viewModel: CaptureViewModel, viewModel: CaptureViewModel,
onStartCapture: () -> Unit, onStartCapture: () -> Unit,
onStopCapture: () -> Unit, onStopCapture: () -> Unit,
onOpenFile: (File) -> Unit onOpenProxySettings: () -> Unit,
onOpenFiles: () -> Unit,
onOpenAbout: () -> Unit,
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val logs by VpnLog.logs.collectAsState() val logs by VpnLog.logs.collectAsState()
var dropdownExpanded by remember { mutableStateOf(false) }
val context = LocalContext.current val context = LocalContext.current
var enabledMap by remember { mutableStateOf(StepSelectionStore.loadAll(context) as Map<String, Boolean>) } var enabledMap by remember { mutableStateOf(StepSelectionStore.loadAll(context) as Map<String, Boolean>) }
var uploadCode by remember { mutableStateOf(SettingsStore.loadUploadCode(context)) } var menuExpanded by remember { mutableStateOf(false) }
var profileExpanded by remember { mutableStateOf(false) }
var showUploadCodeDialog by remember { mutableStateOf(false) }
val isRunning = state.status == CaptureStatus.RUNNING val isRunning = state.status == CaptureStatus.RUNNING
val isCaptured = state.status == CaptureStatus.CAPTURED val isCaptured = state.status == CaptureStatus.CAPTURED
@@ -85,40 +57,52 @@ fun CaptureScreen(
TopAppBar( TopAppBar(
title = { Text("咖喱烧烤无ROOT抓包") }, title = { Text("咖喱烧烤无ROOT抓包") },
actions = { actions = {
IconButton(onClick = { VpnLog.clear() }) {
Icon(
painter = painterResource(id = R.drawable.delete_24px),
contentDescription = "清空日志"
)
}
Box { Box {
TextButton( IconButton(onClick = { menuExpanded = true }) {
onClick = { dropdownExpanded = true },
enabled = !isRunning
) {
Text(
text = selected?.name ?: "无配置",
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Icon( Icon(
painter = painterResource(id = R.drawable.arrow_drop_down_24px), painter = painterResource(id = R.drawable.more_vert_24px),
contentDescription = null contentDescription = "菜单"
) )
} }
DropdownMenu( DropdownMenu(
expanded = dropdownExpanded, expanded = menuExpanded,
onDismissRequest = { dropdownExpanded = false } onDismissRequest = { menuExpanded = false }
) { ) {
state.profiles.forEach { profile -> DropdownMenuItem(
DropdownMenuItem( text = { Text("上传码") },
text = { Text(profile.name) }, onClick = {
onClick = { menuExpanded = false
viewModel.selectProfile(profile) showUploadCodeDialog = true
dropdownExpanded = false }
} )
) DropdownMenuItem(
} text = { Text("代理设置") },
onClick = {
menuExpanded = false
onOpenProxySettings()
}
)
DropdownMenuItem(
text = { Text("已抓文件") },
onClick = {
menuExpanded = false
onOpenFiles()
}
)
DropdownMenuItem(
text = { Text("清空日志") },
onClick = {
menuExpanded = false
VpnLog.clear()
}
)
DropdownMenuItem(
text = { Text("关于") },
onClick = {
menuExpanded = false
onOpenAbout()
}
)
} }
} }
} }
@@ -132,19 +116,40 @@ fun CaptureScreen(
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
OutlinedTextField( Box(modifier = Modifier.fillMaxWidth()) {
value = uploadCode, OutlinedButton(
onValueChange = { onClick = { profileExpanded = true },
uploadCode = it.trim() enabled = !isRunning,
SettingsStore.saveUploadCode(context, uploadCode) modifier = Modifier
}, .fillMaxWidth()
modifier = Modifier.fillMaxWidth(), .height(48.dp)
enabled = !isRunning, ) {
singleLine = true, Text(
label = { Text("上传码(从 Haruki 工具箱 - 数据上传 - iOS 获取)") }, text = "配置:${selected?.name ?: "未选择"}",
textStyle = MaterialTheme.typography.bodyMedium, maxLines = 1,
visualTransformation = PasswordVisualTransformation() overflow = TextOverflow.Ellipsis,
) modifier = Modifier.weight(1f)
)
Icon(
painter = painterResource(id = R.drawable.arrow_drop_down_24px),
contentDescription = null
)
}
DropdownMenu(
expanded = profileExpanded,
onDismissRequest = { profileExpanded = false }
) {
state.profiles.forEach { profile ->
DropdownMenuItem(
text = { Text(profile.name) },
onClick = {
viewModel.selectProfile(context, profile)
profileExpanded = false
}
)
}
}
}
if (selected != null) { if (selected != null) {
StepSelector( StepSelector(
@@ -191,7 +196,11 @@ fun CaptureScreen(
} }
OutlinedButton( OutlinedButton(
onClick = { state.lastCapturedFile?.let(onOpenFile) }, onClick = {
if (state.lastCapturedFile != null) {
onOpenFiles()
}
},
enabled = isCaptured && state.lastCapturedFile != null, enabled = isCaptured && state.lastCapturedFile != null,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -206,6 +215,48 @@ fun CaptureScreen(
} }
} }
} }
if (showUploadCodeDialog) {
UploadCodeDialog(onDismiss = { showUploadCodeDialog = false })
}
}
@Composable
private fun UploadCodeDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
var text by remember { mutableStateOf(SettingsStore.loadUploadCode(context)) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("上传码") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = text,
onValueChange = { text = it.trim() },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Haruki 上传码") },
visualTransformation = PasswordVisualTransformation(),
textStyle = MaterialTheme.typography.bodyMedium
)
Text(
text = "从 Haruki 工具箱 - 数据上传 - iOS 获取。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
confirmButton = {
TextButton(onClick = {
SettingsStore.saveUploadCode(context, text)
onDismiss()
}) { Text("保存") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("取消") }
}
)
} }
@Composable @Composable
@@ -328,4 +379,4 @@ private fun StatusLine(status: CaptureStatus, error: String?) {
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
HorizontalDivider() HorizontalDivider()
} }
@@ -6,6 +6,7 @@ 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.PredefinedProfiles import net.bluemangoo.indpjsk.data.PredefinedProfiles
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.vpn.CaptureVpnService import net.bluemangoo.indpjsk.vpn.CaptureVpnService
@@ -45,8 +46,9 @@ class CaptureViewModel : ViewModel() {
) )
) )
fun selectProfile(profile: CaptureProfile) { fun selectProfile(context: Context, profile: CaptureProfile) {
CaptureStateHolder.selectProfile(profile) CaptureStateHolder.selectProfile(profile)
SettingsStore.saveSelectedProfileId(context, profile.id)
} }
fun startCapture(context: Context) { fun startCapture(context: Context) {
@@ -0,0 +1,135 @@
package net.bluemangoo.indpjsk.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import net.bluemangoo.indpjsk.R
import net.bluemangoo.indpjsk.data.CaptureFiles
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CapturedFilesScreen(
onOpenFile: (File) -> Unit,
onBack: () -> Unit,
) {
val context = LocalContext.current
var version by remember { mutableIntStateOf(0) }
val files = remember(version) { CaptureFiles.list(context) }
var confirmClear by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("获取到的文件") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(R.drawable.arrow_back_24px),
contentDescription = "返回"
)
}
},
actions = {
IconButton(
onClick = { confirmClear = true },
enabled = files.isNotEmpty()
) {
Icon(
painter = painterResource(R.drawable.delete_24px),
contentDescription = "清空"
)
}
}
)
}
) { padding ->
if (files.isEmpty()) {
Box(
modifier = Modifier
.padding(padding)
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
"还没有获取到文件",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
modifier = Modifier
.padding(padding)
.fillMaxSize()
) {
items(files, key = { it.absolutePath }) { file ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenFile(file) }
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.file_open_24px),
contentDescription = null
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = file.name,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
if (confirmClear) {
AlertDialog(
onDismissRequest = { confirmClear = false },
title = { Text("清空文件") },
text = { Text("删除全部已获取的文件?") },
confirmButton = {
TextButton(onClick = {
CaptureFiles.clear(context)
version++
confirmClear = false
}) { Text("删除") }
},
dismissButton = {
TextButton(onClick = { confirmClear = false }) { Text("取消") }
}
)
}
}
@@ -0,0 +1,183 @@
package net.bluemangoo.indpjsk.ui
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import net.bluemangoo.indpjsk.R
import net.bluemangoo.indpjsk.data.AppEntry
import net.bluemangoo.indpjsk.data.InstalledApps
import net.bluemangoo.indpjsk.data.SettingsStore
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProxySettingsScreen(
bypassPackages: Set<String>,
onBypassChange: (Set<String>) -> Unit,
onSelectApps: () -> Unit,
onBack: () -> Unit,
) {
val context = LocalContext.current
var proxy by remember { mutableStateOf(SettingsStore.loadProxy(context)) }
var pendingRemove by remember { mutableStateOf<AppEntry?>(null) }
val entries = remember(bypassPackages) {
bypassPackages.sorted().mapNotNull { InstalledApps.get(context, it) }
}
val looksValid = proxy.isBlank() ||
((proxy.startsWith("http://") || proxy.startsWith("https://")) && proxy.contains(":"))
Scaffold(
topBar = {
TopAppBar(
title = { Text("代理设置") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(R.drawable.arrow_back_24px),
contentDescription = "返回"
)
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 0.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("上游代理", style = MaterialTheme.typography.labelLarge)
OutlinedTextField(
value = proxy,
onValueChange = {
proxy = it
SettingsStore.saveProxy(context, it)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("上游代理地址") },
placeholder = { Text("http://127.0.0.1:7890") },
isError = !looksValid,
supportingText = {
Text(
text = if (looksValid) {
"留空以忽略;只支持 http / https 代理"
} else {
"格式示例:http://127.0.0.1:7890"
}
)
}
)
HorizontalDivider()
Text("绕过本应用代理的应用", style = MaterialTheme.typography.labelLarge)
}
Card(
onClick = onSelectApps,
shape = RectangleShape,
colors = CardDefaults.cardColors(
containerColor = Color.Transparent
),
modifier = Modifier
.fillMaxWidth()
) {
Text(
text = "选择应用(${bypassPackages.size})",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
)
}
if (entries.isEmpty()) {
Text(
text = "列表为空",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 0.dp)
)
} else {
entries.forEach { entry ->
BypassAppRow(entry = entry, onLongPress = { pendingRemove = entry })
}
}
}
}
pendingRemove?.let { entry ->
AlertDialog(
onDismissRequest = { pendingRemove = null },
title = { Text("移除应用") },
text = { Text("从应用列表移除「${entry.label}」?") },
confirmButton = {
TextButton(onClick = {
onBypassChange(bypassPackages - entry.packageName)
pendingRemove = null
}) { Text("移除") }
},
dismissButton = {
TextButton(onClick = { pendingRemove = null }) { Text("取消") }
}
)
}
}
@Composable
private fun BypassAppRow(entry: AppEntry, onLongPress: () -> Unit) {
Card(
shape = RectangleShape,
colors = CardDefaults.cardColors(
containerColor = Color.Transparent
),
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {},
onLongClick = onLongPress
)
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppIcon(icon = entry.icon, label = entry.label, size = 32.dp)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = entry.label,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = entry.packageName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@@ -19,20 +19,6 @@ import net.bluemangoo.indpjsk.state.CaptureStateHolder
import net.bluemangoo.indpjsk.upload.Uploader import net.bluemangoo.indpjsk.upload.Uploader
import java.io.File import java.io.File
/**
* 多步骤抓包:
* establish() 只做一次,同一个 tun fd 按顺序喂给每一步,中间不拆 VPN。
* 每一步抓到后异步跑它的 postAction(不阻塞下一步的监听)。
*
* 停止机制:
* ACTION_STOP → requestStop() + close(activePfd),当前步骤的 runCapture 返回 null,
* 循环发现 stopRequested 后终止后续步骤。
*
* fd 所有权:
* 全生命周期在 Android 侧持有。传给 Rust 的是 pfd.fd(int 缓存值),
* Rust 内部用 BorrowedAsyncDevice 借用,不做任何 close。
* Android 在 finally 里统一 close(activePfd)。
*/
@SuppressLint("VpnServicePolicy") @SuppressLint("VpnServicePolicy")
class CaptureVpnService : VpnService() { class CaptureVpnService : VpnService() {
@@ -44,14 +30,8 @@ class CaptureVpnService : VpnService() {
private const val VPN_ADDRESS = "10.7.0.2" private const val VPN_ADDRESS = "10.7.0.2"
private const val VPN_PREFIX = 32 private const val VPN_PREFIX = 32
/**
* 后处理(上传)用的 scope:进程级,故意不随 Service 销毁取消。
* 否则 stopSelf() → onDestroy 会把还在上传的分片掐掉(Job was cancelled)。
*/
private val postScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val postScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/** 捕获文件统一放在 filesDir/captures/<profileId>/ 下(file_paths.xml 的 FileProvider 路径) */
fun captureFile( fun captureFile(
filesDir: File, filesDir: File,
profile: CaptureProfile, profile: CaptureProfile,
@@ -59,14 +39,12 @@ class CaptureVpnService : VpnService() {
): File = File(File(File(filesDir, "captures"), profile.id), step.fileName) ): File = File(File(File(filesDir, "captures"), profile.id), step.fileName)
} }
/** 工作线程持有;供 ACTION_STOP 从主线程关闭以中断 Rust */
@Volatile @Volatile
private var activePfd: ParcelFileDescriptor? = null private var activePfd: ParcelFileDescriptor? = null
@Volatile @Volatile
private var captureThread: Thread? = null private var captureThread: Thread? = null
/** 区分"用户主动停止"与"抓到包后自然结束/失败" */
@Volatile @Volatile
private var stopRequested = false private var stopRequested = false
@@ -84,7 +62,6 @@ class CaptureVpnService : VpnService() {
VpnLog.warn("手动停止…") VpnLog.warn("手动停止…")
VpnBridge.requestStop() VpnBridge.requestStop()
closeActivePfd() closeActivePfd()
// 立即把 UI 从 RUNNING 拉回来;capture 线程退出时还会再写一次
CaptureStateHolder.setIdle() CaptureStateHolder.setIdle()
stopSelf() stopSelf()
} }
@@ -111,7 +88,6 @@ class CaptureVpnService : VpnService() {
return return
} }
// 按 profile 里的顺序,过滤掉没勾选的步骤(默认全勾)
val savedSelection = StepSelectionStore.loadAll(this) val savedSelection = StepSelectionStore.loadAll(this)
val enabledSteps = profile.steps.filter { step -> val enabledSteps = profile.steps.filter { step ->
StepSelectionStore.isEnabled(savedSelection, profile.id, step.id) StepSelectionStore.isEnabled(savedSelection, profile.id, step.id)
@@ -122,12 +98,18 @@ class CaptureVpnService : VpnService() {
return return
} }
// 上传码在开始时快照,运行中改 UI 不影响本次
val uploadCode = SettingsStore.loadUploadCode(this).trim() val uploadCode = SettingsStore.loadUploadCode(this).trim()
if (uploadCode.isNotEmpty() && uploadCode.length != 32) { if (uploadCode.isNotEmpty() && uploadCode.length != 32) {
VpnLog.warn("上传码长度不是 32 位(当前 ${uploadCode.length} 位),上传大概率会失败") VpnLog.warn("上传码长度不是 32 位(当前 ${uploadCode.length} 位),上传大概率会失败")
} }
val proxy = SettingsStore.loadProxy(this).trim()
val bypassPackages = SettingsStore.loadBypassPackages(this)
if (proxy.isNotEmpty()) VpnLog.info("上游代理: $proxy")
if (bypassPackages.isNotEmpty()) {
VpnLog.info("忽略的应用: ${bypassPackages.joinToString(", ")}")
}
stopRequested = false stopRequested = false
VpnLog.info("==== 开始抓包 ====") VpnLog.info("==== 开始抓包 ====")
VpnLog.info("配置: ${profile.name}") VpnLog.info("配置: ${profile.name}")
@@ -144,11 +126,10 @@ class CaptureVpnService : VpnService() {
throw IllegalStateException("Rust 动态库 core_vpn 未加载") throw IllegalStateException("Rust 动态库 core_vpn 未加载")
} }
// 2) 建立 VPN:仅 /32 目标路由 + 排除自身(防止 Rust 转发流量回环)
val builder = Builder() val builder = Builder()
.setSession("Capture-${profile.id}") .setSession("Capture-${profile.id}")
.addAddress(VPN_ADDRESS, VPN_PREFIX) .addAddress(VPN_ADDRESS, VPN_PREFIX)
.addDisallowedApplication(packageName)// 1.0.0.0/8 (排除 0.0.0.0/8 本地识别地址) .addDisallowedApplication(packageName)
.addRoute("1.0.0.0", 8) .addRoute("1.0.0.0", 8)
.addRoute("2.0.0.0", 7) .addRoute("2.0.0.0", 7)
@@ -207,6 +188,12 @@ class CaptureVpnService : VpnService() {
.addDnsServer("8.8.8.8") .addDnsServer("8.8.8.8")
.addDnsServer("114.114.114.114") .addDnsServer("114.114.114.114")
for (pkg in bypassPackages) {
if (pkg == packageName) continue
runCatching { builder.addDisallowedApplication(pkg) }
.onFailure { VpnLog.warn("忽略应用 $pkg 失败: ${it.message}") }
}
pfd = builder.establish() pfd = builder.establish()
?: throw IllegalStateException("VpnService.establish() 返回 null") ?: throw IllegalStateException("VpnService.establish() 返回 null")
activePfd = pfd activePfd = pfd
@@ -224,12 +211,12 @@ class CaptureVpnService : VpnService() {
target.parentFile?.mkdirs() target.parentFile?.mkdirs()
if (target.exists()) target.delete() if (target.exists()) target.delete()
// 阻塞调用 Rust,直到抓到本步骤的目标请求(或停止 / 出错)
val capturedUrl = VpnBridge.runCapture( val capturedUrl = VpnBridge.runCapture(
fd, fd,
step.hosts.joinToString(","), step.hosts.joinToString(","),
step.path, step.path,
target.absolutePath target.absolutePath,
proxy
) )
if (capturedUrl == null) { if (capturedUrl == null) {
@@ -251,7 +238,6 @@ class CaptureVpnService : VpnService() {
completed++ completed++
VpnLog.success("$label 抓取成功") VpnLog.success("$label 抓取成功")
// 抓到后异步做后处理(上传 / 该步骤自己的 postAction),不阻塞下一步监听
val result = CaptureResult(profile, step, capturedUrl, target) val result = CaptureResult(profile, step, capturedUrl, target)
postScope.launch { postScope.launch {
try { try {
@@ -274,7 +260,6 @@ class CaptureVpnService : VpnService() {
errorMsg = t.message ?: "抓包异常" errorMsg = t.message ?: "抓包异常"
VpnLog.error("抓包异常: ${t.message}") VpnLog.error("抓包异常: ${t.message}")
} finally { } finally {
// 关闭 fd(若 ACTION_STOP 已关,这次 close 是幂等的/会抛可忽略异常)
closeActivePfd() closeActivePfd()
VpnLog.info("==== 抓包结束($completed/${enabledSteps.size} 步完成)====") VpnLog.info("==== 抓包结束($completed/${enabledSteps.size} 步完成)====")
@@ -6,10 +6,11 @@ import android.util.Log
* Rust core_vpn 的 JNI 桥。 * Rust core_vpn 的 JNI 桥。
* *
* 契约(由 Rust lib.rs 实现): * 契约(由 Rust lib.rs 实现):
* runCapture(jint tunFd, jstring targetHosts, jstring path, jstring savePath) -> String? * runCapture(jint tunFd, jstring targetHosts, jstring path, jstring savePath, jstring proxy) -> String?
* - 阻塞直到抓包结束 * - 阻塞直到抓包结束
* - tunFd 的所有权【仍在 Kotlin 侧】,Rust 内部用 BorrowedAsyncDevice 借用,不得 close() * - tunFd 的所有权【仍在 Kotlin 侧】,Rust 内部用 BorrowedAsyncDevice 借用,不得 close()
* - targetHosts 是逗号分隔的域名白名单(一个步骤可能对应多个上游域名) * - targetHosts 是逗号分隔的域名白名单(一个步骤可能对应多个上游域名)
* - proxy 可为 null / 空串(直连),否则必须是 http(s)://host:port 形式,Rust 侧会再校验一次
* - 返回非 null = 抓到目标请求,值是命中的原始完整 URL(https://host/path?query),文件已写入 savePath * - 返回非 null = 抓到目标请求,值是命中的原始完整 URL(https://host/path?query),文件已写入 savePath
* - 返回 null = 失败 / 被 requestStop 打断 / 未匹配 * - 返回 null = 失败 / 被 requestStop 打断 / 未匹配
* *
@@ -42,7 +43,8 @@ object VpnBridge {
tunFd: Int, tunFd: Int,
targetHosts: String, targetHosts: String,
targetPath: String, targetPath: String,
savePath: String savePath: String,
proxy: String?
): String? ): String?
@JvmStatic @JvmStatic
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M313,520L537,744L480,800L160,480L480,160L537,216L313,440L800,440L800,520L313,520Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,207 423.5,183.5Q447,160 480,160Q513,160 536.5,183.5Q560,207 560,240Q560,273 536.5,296.5Q513,320 480,320Z"/>
</vector>
+259 -1
View File
@@ -61,7 +61,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
"synstructure", "synstructure 0.13.2",
] ]
[[package]] [[package]]
@@ -264,6 +264,7 @@ name = "core_vpn"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64",
"bytes", "bytes",
"http", "http",
"http-body-util", "http-body-util",
@@ -281,6 +282,7 @@ dependencies = [
"tokio-rustls", "tokio-rustls",
"tokio-util", "tokio-util",
"tun-rs", "tun-rs",
"url",
"webpki-roots", "webpki-roots",
] ]
@@ -422,6 +424,15 @@ dependencies = [
"spin", "spin",
] ]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]] [[package]]
name = "fs_extra" name = "fs_extra"
version = "1.3.0" version = "1.3.0"
@@ -595,6 +606,110 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "icu_collections"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc"
[[package]]
name = "icu_properties"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f"
dependencies = [
"displaydoc",
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf"
[[package]]
name = "icu_provider"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.2" version = "2.12.2"
@@ -713,6 +828,12 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -1003,6 +1124,15 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "potential_utf"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"zerovec",
]
[[package]] [[package]]
name = "powerfmt" name = "powerfmt"
version = "0.2.1" version = "0.2.1"
@@ -1379,6 +1509,12 @@ dependencies = [
"lock_api", "lock_api",
] ]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@@ -1418,6 +1554,17 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "synstructure"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]] [[package]]
name = "system-configuration-sys" name = "system-configuration-sys"
version = "0.6.0" version = "0.6.0"
@@ -1478,6 +1625,16 @@ dependencies = [
"time-core", "time-core",
] ]
[[package]]
name = "tinystr"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.54.1" version = "1.54.1"
@@ -1597,6 +1754,24 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
@@ -1921,6 +2096,12 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]] [[package]]
name = "x509-parser" name = "x509-parser"
version = "0.18.1" version = "0.18.1"
@@ -1949,6 +2130,29 @@ dependencies = [
"time", "time",
] ]
[[package]]
name = "yoke"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
"synstructure 0.14.0",
]
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.63" version = "0.8.63"
@@ -1969,12 +2173,66 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
"synstructure 0.14.0",
]
[[package]] [[package]]
name = "zeroize" name = "zeroize"
version = "1.9.1" version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
[[package]]
name = "zerotrie"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.23" version = "1.0.23"
+2
View File
@@ -26,6 +26,8 @@ hyper-util = { version = "0.1", features = ["tokio", "server", "client"] }
http-body-util = "0.1" http-body-util = "0.1"
http = "1.5.0" http = "1.5.0"
qstring = "0.7.2" qstring = "0.7.2"
url = "2"
base64 = "0.23.1"
[build-dependencies] [build-dependencies]
rcgen = { version = "0.14", features = ["x509-parser"] } rcgen = { version = "0.14", features = ["x509-parser"] }
+21 -3
View File
@@ -3,7 +3,7 @@ mod mitm;
mod tun; mod tun;
mod mut_qstring; mod mut_qstring;
use crate::mitm::run_capture_async; use crate::mitm::{ProxyConfig, run_capture_async};
use jni::objects::{Global, JClass, JObject, JString}; use jni::objects::{Global, JClass, JObject, JString};
use jni::sys::{jint, jstring}; use jni::sys::{jint, jstring};
use jni::errors::ThrowRuntimeExAndDefault; use jni::errors::ThrowRuntimeExAndDefault;
@@ -89,6 +89,7 @@ pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_runCapture(
target_host: JString, target_host: JString,
target_path: JString, target_path: JString,
save_path: JString, save_path: JString,
proxy: JString,
) -> jstring { ) -> jstring {
let _ = env.with_env(|env: &mut Env| -> Result<(), jni::errors::Error> { let _ = env.with_env(|env: &mut Env| -> Result<(), jni::errors::Error> {
init_jvm(env); init_jvm(env);
@@ -118,13 +119,30 @@ pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_runCapture(
} }
}; };
let proxy_raw: String = if proxy.is_null() {
String::new()
} else {
proxy.to_string()
};
let proxy_cfg = match ProxyConfig::parse(&proxy_raw) {
Ok(cfg) => cfg,
Err(e) => {
error!("代理配置无效: {e}");
return std::ptr::null_mut();
}
};
if let Some(cfg) = &proxy_cfg {
info!("upstream proxy: {}://{}:{}", if cfg.tls { "https" } else { "http" }, cfg.host, cfg.port);
}
let rt = tokio::runtime::Runtime::new().unwrap(); let rt = tokio::runtime::Runtime::new().unwrap();
let cancel_token = CancellationToken::new(); let cancel_token = CancellationToken::new();
*CANCLE_TOKEN.write().unwrap() = Some(cancel_token.clone()); *CANCLE_TOKEN.write().unwrap() = Some(cancel_token.clone());
let cancel_token_clone = cancel_token.clone(); let cancel_token_clone = cancel_token.clone();
let result = let result = rt.block_on(async move {
rt.block_on(async move { run_capture_async(tun_fd, hosts, re, out, cancel_token_clone).await }); run_capture_async(tun_fd, hosts, re, out, proxy_cfg, cancel_token_clone).await
});
cancel_token.cancel(); cancel_token.cancel();
rt.shutdown_timeout(std::time::Duration::from_secs(2)); rt.shutdown_timeout(std::time::Duration::from_secs(2));
+181 -18
View File
@@ -14,6 +14,7 @@ use std::net::SocketAddr;
use std::os::fd::RawFd; use std::os::fd::RawFd;
use std::sync::{Arc, OnceLock}; use std::sync::{Arc, 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::sync::Mutex; use tokio::sync::Mutex;
@@ -112,6 +113,7 @@ where
async fn handle_tls_connection<S>( async fn handle_tls_connection<S>(
tcp: S, tcp: S,
dst: SocketAddr, dst: SocketAddr,
upstream: Upstream,
target_hosts: Vec<String>, target_hosts: Vec<String>,
target_path: Regex, target_path: Regex,
save_path: String, save_path: String,
@@ -134,7 +136,8 @@ where
let Some((sni, accepted)) = sniffed else { let Some((sni, accepted)) = sniffed else {
io.rewind(); io.rewind();
io.stop_recording(); io.stop_recording();
return passthrough_cancel_wrapped(io, dst, &cancel).await; // 非 TLS:拿不到 SNI,走代理时只能按 IP CONNECT
return passthrough_cancel_wrapped(io, dst, None, upstream, &cancel).await;
}; };
let host_matched = sni let host_matched = sni
@@ -145,7 +148,7 @@ where
if !host_matched { if !host_matched {
io.rewind(); io.rewind();
io.stop_recording(); io.stop_recording();
let r = passthrough_cancel_wrapped(io, dst, &cancel).await; let r = passthrough_cancel_wrapped(io, dst, sni.as_deref(), upstream, &cancel).await;
if r.is_err() { if r.is_err() {
warn!("tcp_passthrough error on {sni:?} {dst:?}"); warn!("tcp_passthrough error on {sni:?} {dst:?}");
} }
@@ -159,14 +162,16 @@ where
.await?; .await?;
let svc_host = sni.clone().unwrap_or_default(); let svc_host = sni.clone().unwrap_or_default();
let svc_upstream = upstream;
let svc = hyper::service::service_fn(move |req: hyper::Request<Incoming>| { let svc = hyper::service::service_fn(move |req: hyper::Request<Incoming>| {
let host = svc_host.clone(); let host = svc_host.clone();
let path = target_path.clone(); let path = target_path.clone();
let save = save_path.clone(); let save = save_path.clone();
let result_tx = result_tx.clone(); let result_tx = result_tx.clone();
let upstream = svc_upstream.clone();
async move { async move {
Ok::<_, std::convert::Infallible>( Ok::<_, std::convert::Infallible>(
forward_request(req, dst, host, path, save, result_tx).await, forward_request(req, dst, host, path, save, result_tx, upstream).await,
) )
} }
}); });
@@ -199,6 +204,8 @@ where
async fn passthrough_cancel_wrapped<IO>( async fn passthrough_cancel_wrapped<IO>(
io: IO, io: IO,
dst: SocketAddr, dst: SocketAddr,
sni: Option<&str>,
upstream: Upstream,
cancel: &CancellationToken, cancel: &CancellationToken,
) -> anyhow::Result<()> ) -> anyhow::Result<()>
where where
@@ -206,7 +213,7 @@ where
{ {
tokio::select! { tokio::select! {
_ = cancel.cancelled() => Ok(()), _ = cancel.cancelled() => Ok(()),
r = tcp_passthrough(io, dst) => r, r = tcp_passthrough(io, dst, sni, upstream) => r,
} }
} }
@@ -218,8 +225,9 @@ async fn forward_request(
target_path: Regex, target_path: Regex,
save_path: String, save_path: String,
result_tx: Arc<Mutex<Option<tokio::sync::oneshot::Sender<String>>>>, result_tx: Arc<Mutex<Option<tokio::sync::oneshot::Sender<String>>>>,
upstream: Upstream,
) -> hyper::Response<Full<Bytes>> { ) -> hyper::Response<Full<Bytes>> {
match forward_request_inner(req, dst, &url_host, &target_path).await { match forward_request_inner(req, dst, &url_host, &target_path, &upstream).await {
Ok((response, matched_body)) => { Ok((response, matched_body)) => {
if let Some((url, body)) = matched_body { if let Some((url, body)) = matched_body {
info!("Target matched! Downloading {} bytes", body.len()); info!("Target matched! Downloading {} bytes", body.len());
@@ -257,6 +265,7 @@ async fn forward_request_inner(
dst: SocketAddr, dst: SocketAddr,
url_host: &str, url_host: &str,
target_path: &Regex, target_path: &Regex,
upstream: &Upstream,
) -> anyhow::Result<(hyper::Response<Full<Bytes>>, Option<(String, Bytes)>)> { ) -> anyhow::Result<(hyper::Response<Full<Bytes>>, Option<(String, Bytes)>)> {
let (mut parts, body) = req.into_parts(); let (mut parts, body) = req.into_parts();
@@ -293,7 +302,7 @@ async fn forward_request_inner(
client_config.alpn_protocols = vec![b"http/1.1".to_vec()]; client_config.alpn_protocols = vec![b"http/1.1".to_vec()];
let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config)); let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config));
let server_name = rustls::pki_types::ServerName::try_from(url_host.to_string())?; let server_name = rustls::pki_types::ServerName::try_from(url_host.to_string())?;
let tcp_to_server = tokio::net::TcpStream::connect(dst).await?; let tcp_to_server = upstream.connect(dst, Some(url_host)).await?;
let server_tls = connector.connect(server_name, tcp_to_server).await?; let server_tls = connector.connect(server_name, tcp_to_server).await?;
let (mut sender, conn) = let (mut sender, conn) =
@@ -335,7 +344,7 @@ async fn forward_request_inner(
)) ))
} }
fn build_url(host: &str, uri: &http::Uri) -> String { fn build_url(host: &str, uri: &Uri) -> String {
if uri.scheme().is_some() && uri.authority().is_some() { if uri.scheme().is_some() && uri.authority().is_some() {
return uri.to_string(); return uri.to_string();
} }
@@ -343,15 +352,155 @@ fn build_url(host: &str, uri: &http::Uri) -> String {
format!("https://{host}{pq}") format!("https://{host}{pq}")
} }
fn force_full_mysekai(uri: &http::Uri) -> Option<http::Uri> { /// 把 MySekai 的增量刷新标志强制改成 True(和模块的 URL Rewrite 一致),
if let q = Some(uri.query().is_some()) { /// 其它 query 参数原样保留。没有这个参数、或已经是 True 时返回 None。
let mut query = qstring::QString::from(q); fn force_full_mysekai(uri: &Uri) -> Option<Uri> {
if query.has("isForceAllReloadOnlyMysekai") { const KEY: &str = "isForceAllReloadOnlyMysekai";
query.set("isForceAllReloadOnlyMysekai", "true"); let mut query = qstring::QString::from(uri.query()?);
return Some(http::Uri::try_from(query.to_string().as_str()).ok()?); if !query.has(KEY) || query.get(KEY) == Some("True") {
return None;
}
query.set(KEY, "True");
Uri::try_from(format!("{}?{}", uri.path(), query).as_str()).ok()
}
#[derive(Clone, Debug)]
pub struct ProxyConfig {
pub tls: bool,
pub host: String,
pub port: u16,
pub auth: Option<String>,
}
impl ProxyConfig {
pub fn parse(input: &str) -> anyhow::Result<Option<Self>> {
let raw = input.trim();
if raw.is_empty() {
return Ok(None);
}
let url = url::Url::parse(raw)
.map_err(|e| anyhow::anyhow!("代理地址不是合法 URL: {raw} ({e})"))?;
let tls = match url.scheme() {
"http" => false,
"https" => true,
other => anyhow::bail!("代理只支持 http/https,当前是 {other}://"),
};
let host = url
.host_str()
.filter(|h| !h.is_empty())
.ok_or_else(|| anyhow::anyhow!("代理地址缺少 host: {raw}"))?
.to_string();
let port = url
.port_or_known_default()
.ok_or_else(|| anyhow::anyhow!("代理地址缺少端口: {raw}"))?;
let auth = if url.username().is_empty() {
None
} else {
let pass = url.password().unwrap_or("");
Some(base64_encode(format!("{}:{}", url.username(), pass).as_bytes()))
};
Ok(Some(Self { tls, host, port, auth }))
}
}
fn base64_encode(data: &[u8]) -> String {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(data)
}
type UpstreamIo = Box<dyn AsyncReadWrite + Send + Unpin>;
trait AsyncReadWrite: AsyncRead + AsyncWrite {}
impl<T: AsyncRead + AsyncWrite + ?Sized> AsyncReadWrite for T {}
#[derive(Clone)]
struct Upstream {
proxy: Option<Arc<ProxyConfig>>,
}
impl Upstream {
fn new(proxy: Option<ProxyConfig>) -> Self {
Self { proxy: proxy.map(Arc::new) }
}
async fn connect(&self, dst: SocketAddr, host: Option<&str>) -> anyhow::Result<UpstreamIo> {
match &self.proxy {
None => {
let tcp = tokio::net::TcpStream::connect(dst).await?;
tcp.set_nodelay(true).ok();
Ok(Box::new(tcp))
}
Some(proxy) => connect_via_proxy(proxy, dst, host).await,
} }
} }
None }
async fn connect_via_proxy(
proxy: &ProxyConfig,
dst: SocketAddr,
host: Option<&str>,
) -> anyhow::Result<UpstreamIo> {
let tcp = tokio::net::TcpStream::connect((proxy.host.as_str(), proxy.port))
.await
.map_err(|e| anyhow::anyhow!("连接代理 {}:{} 失败: {e}", proxy.host, proxy.port))?;
tcp.set_nodelay(true).ok();
let mut io: UpstreamIo = if proxy.tls {
let mut cfg = rustls::ClientConfig::builder()
.with_root_certificates(root_store())
.with_no_client_auth();
cfg.alpn_protocols = vec![b"http/1.1".to_vec()];
let connector = tokio_rustls::TlsConnector::from(Arc::new(cfg));
let name = rustls::pki_types::ServerName::try_from(proxy.host.clone())?;
Box::new(
connector
.connect(name, tcp)
.await
.map_err(|e| anyhow::anyhow!("与代理建立 TLS 失败: {e}"))?,
)
} else {
Box::new(tcp)
};
let authority = match host {
Some(h) if !h.is_empty() => format!("{h}:{}", dst.port()),
_ => dst.to_string(),
};
let mut req = format!("CONNECT {authority} HTTP/1.1\r\nHost: {authority}\r\n");
if let Some(auth) = &proxy.auth {
req.push_str(&format!("Proxy-Authorization: Basic {auth}\r\n"));
}
req.push_str("\r\n");
io.write_all(req.as_bytes()).await?;
io.flush().await?;
let mut head = Vec::with_capacity(256);
let mut byte = [0u8; 1];
loop {
let n = io.read(&mut byte).await?;
if n == 0 {
anyhow::bail!("代理在 CONNECT 阶段直接断开");
}
head.push(byte[0]);
if head.ends_with(b"\r\n\r\n") {
break;
}
if head.len() > 16 * 1024 {
anyhow::bail!("代理 CONNECT 响应头过长");
}
}
let head_str = String::from_utf8_lossy(&head);
let status_line = head_str.lines().next().unwrap_or("").trim().to_string();
let ok = status_line
.split_whitespace()
.nth(1)
.map(|code| code == "200")
.unwrap_or(false);
if !ok {
anyhow::bail!("代理 CONNECT 失败: {status_line}");
}
Ok(io)
} }
pub async fn run_capture_async( pub async fn run_capture_async(
@@ -359,6 +508,7 @@ pub async fn run_capture_async(
target_hosts: Vec<String>, target_hosts: Vec<String>,
target_path: Regex, target_path: Regex,
save_path: String, save_path: String,
proxy: Option<ProxyConfig>,
cancel_token: CancellationToken, cancel_token: CancellationToken,
) -> anyhow::Result<Option<String>> { ) -> anyhow::Result<Option<String>> {
let device = unsafe { BorrowedAsyncDevice::borrow_raw(tun_fd as RawFd)? }; let device = unsafe { BorrowedAsyncDevice::borrow_raw(tun_fd as RawFd)? };
@@ -371,6 +521,7 @@ pub async fn run_capture_async(
let mut bridge_tasks = JoinSet::new(); let mut bridge_tasks = JoinSet::new();
let tls_acceptor = build_tls_acceptor(); let tls_acceptor = build_tls_acceptor();
let upstream = Upstream::new(proxy);
let (result_tx, mut result_rx) = tokio::sync::oneshot::channel::<String>(); let (result_tx, mut result_rx) = tokio::sync::oneshot::channel::<String>();
let result_tx = Arc::new(Mutex::new(Some(result_tx))); let result_tx = Arc::new(Mutex::new(Some(result_tx)));
@@ -442,11 +593,13 @@ pub async fn run_capture_async(
let server_config = tls_acceptor.clone(); let server_config = tls_acceptor.clone();
let token = cancel_token.clone(); let token = cancel_token.clone();
let result_tx = result_tx.clone(); let result_tx = result_tx.clone();
let upstream = upstream.clone();
tokio::spawn(async move { tokio::spawn(async move {
match handle_tls_connection( match handle_tls_connection(
app_io, app_io,
dst, dst,
upstream,
hosts, hosts,
path, path,
save, save,
@@ -460,8 +613,11 @@ pub async fn run_capture_async(
}); });
}else{ }else{
let pump_token = cancel_token.clone(); let pump_token = cancel_token.clone();
let upstream = upstream.clone();
bridge_tasks.spawn(async move { bridge_tasks.spawn(async move {
let _ = tcp_passthrough_cancellable(tcp, dst, pump_token).await; let _ =
tcp_passthrough_cancellable(tcp, dst, None, upstream, pump_token)
.await;
}); });
} }
} }
@@ -481,11 +637,16 @@ pub async fn run_capture_async(
result result
} }
async fn tcp_passthrough<IO>(mut client: IO, dst: SocketAddr) -> anyhow::Result<()> async fn tcp_passthrough<IO>(
mut client: IO,
dst: SocketAddr,
sni: Option<&str>,
upstream: Upstream,
) -> anyhow::Result<()>
where where
IO: AsyncRead + AsyncWrite + Unpin + Send + 'static, IO: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{ {
let mut server = tokio::net::TcpStream::connect(dst).await?; let mut server = upstream.connect(dst, sni).await?;
tokio::io::copy_bidirectional(&mut client, &mut server).await?; tokio::io::copy_bidirectional(&mut client, &mut server).await?;
Ok(()) Ok(())
} }
@@ -493,12 +654,14 @@ where
async fn tcp_passthrough_cancellable<IO>( async fn tcp_passthrough_cancellable<IO>(
mut client: IO, mut client: IO,
dst: SocketAddr, dst: SocketAddr,
sni: Option<&str>,
upstream: Upstream,
cancel: CancellationToken, cancel: CancellationToken,
) -> anyhow::Result<()> ) -> anyhow::Result<()>
where where
IO: AsyncRead + AsyncWrite + Unpin + Send + 'static, IO: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{ {
let mut server = tokio::net::TcpStream::connect(dst).await?; let mut server = upstream.connect(dst, sni).await?;
tokio::select! { tokio::select! {
_ = cancel.cancelled() => { _ = cancel.cancelled() => {
let _ = tokio::time::timeout( let _ = tokio::time::timeout(