diff --git a/android/app/src/main/java/com/masterdns/vpn/data/local/AppDatabase.kt b/android/app/src/main/java/com/masterdns/vpn/data/local/AppDatabase.kt index 221aeea..841a22a 100644 --- a/android/app/src/main/java/com/masterdns/vpn/data/local/AppDatabase.kt +++ b/android/app/src/main/java/com/masterdns/vpn/data/local/AppDatabase.kt @@ -4,8 +4,10 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase -@Database(entities = [ProfileEntity::class], version = 1, exportSchema = false) +@Database(entities = [ProfileEntity::class], version = 2, exportSchema = false) abstract class AppDatabase : RoomDatabase() { abstract fun profileDao(): ProfileDao @@ -13,13 +15,25 @@ abstract class AppDatabase : RoomDatabase() { @Volatile private var INSTANCE: AppDatabase? = null + private val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE profiles ADD COLUMN resolverSourceType TEXT NOT NULL DEFAULT 'INLINE'") + db.execSQL("ALTER TABLE profiles ADD COLUMN resolverFileName TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE profiles ADD COLUMN resolverCachedPath TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE profiles ADD COLUMN resolverStatsJson TEXT NOT NULL DEFAULT ''") + } + } + fun getInstance(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { INSTANCE ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, "masterdns_vpn.db" - ).build().also { INSTANCE = it } + ) + .addMigrations(MIGRATION_1_2) + .build() + .also { INSTANCE = it } } } } diff --git a/android/app/src/main/java/com/masterdns/vpn/data/local/ProfileEntity.kt b/android/app/src/main/java/com/masterdns/vpn/data/local/ProfileEntity.kt index 61a4489..73d5c0b 100644 --- a/android/app/src/main/java/com/masterdns/vpn/data/local/ProfileEntity.kt +++ b/android/app/src/main/java/com/masterdns/vpn/data/local/ProfileEntity.kt @@ -13,6 +13,10 @@ data class ProfileEntity( val protocolType: String = "SOCKS5", // SOCKS5 or TCP val listenPort: Int = 18000, val resolvers: String = "", // newline-separated list + val resolverSourceType: String = "INLINE", // INLINE or FILE + val resolverFileName: String = "", + val resolverCachedPath: String = "", + val resolverStatsJson: String = "", val resolverBalancingStrategy: Int = 2, val packetDuplicationCount: Int = 2, val setupPacketDuplicationCount: Int = 2, diff --git a/android/app/src/main/java/com/masterdns/vpn/service/MasterDnsVpnService.kt b/android/app/src/main/java/com/masterdns/vpn/service/MasterDnsVpnService.kt index cc7be29..7a00de6 100644 --- a/android/app/src/main/java/com/masterdns/vpn/service/MasterDnsVpnService.kt +++ b/android/app/src/main/java/com/masterdns/vpn/service/MasterDnsVpnService.kt @@ -18,6 +18,7 @@ import com.masterdns.vpn.R import com.masterdns.vpn.data.local.AppDatabase import com.masterdns.vpn.util.ConfigGenerator import com.masterdns.vpn.util.GlobalSettingsStore +import com.masterdns.vpn.util.ResolverAnalyzer import com.masterdns.vpn.util.VpnManager import kotlinx.coroutines.* import java.io.File @@ -199,7 +200,25 @@ class MasterDnsVpnService : VpnService() { localDnsPortOverride = if (proxyMode) null else safeDnsPort ) ) - if (runtimeProfile.resolvers.isNotBlank()) { + val importedResolverFile = if ( + runtimeProfile.resolverSourceType == "FILE" && + runtimeProfile.resolverCachedPath.isNotBlank() + ) { + File(runtimeProfile.resolverCachedPath).takeIf { it.isFile } + } else { + null + } + if (importedResolverFile != null) { + importedResolverFile.copyTo(resolversFile, overwrite = true) + VpnManager.appendLog( + "Using imported resolver file: ${ + runtimeProfile.resolverFileName.ifBlank { importedResolverFile.name } + }" + ) + ResolverAnalyzer.statsFromJson(runtimeProfile.resolverStatsJson)?.let { + VpnManager.appendLog("Resolver stats: ${it.summary()}") + } + } else if (runtimeProfile.resolvers.isNotBlank()) { resolversFile.writeText(ConfigGenerator.generateResolvers(runtimeProfile)) } else if (!resolversFile.exists() || resolversFile.readText().isBlank()) { resolversFile.writeText(ConfigGenerator.generateResolvers(runtimeProfile)) diff --git a/android/app/src/main/java/com/masterdns/vpn/ui/home/HomeScreen.kt b/android/app/src/main/java/com/masterdns/vpn/ui/home/HomeScreen.kt index 5d228db..9a3cc76 100644 --- a/android/app/src/main/java/com/masterdns/vpn/ui/home/HomeScreen.kt +++ b/android/app/src/main/java/com/masterdns/vpn/ui/home/HomeScreen.kt @@ -48,6 +48,7 @@ import com.masterdns.vpn.ui.theme.ConnectingAmber import com.masterdns.vpn.ui.theme.DisconnectedRed import com.masterdns.vpn.ui.theme.MdvColor import com.masterdns.vpn.ui.theme.MdvSpace +import com.masterdns.vpn.util.ResolverAnalyzer import com.masterdns.vpn.util.VpnManager private data class HomeLayoutMetrics( @@ -92,15 +93,29 @@ fun HomeScreen( val isConnected = vpnState == VpnManager.VpnState.CONNECTED val isConnecting = vpnState == VpnManager.VpnState.CONNECTING val isDisconnecting = vpnState == VpnManager.VpnState.DISCONNECTING - val profileResolversCount = selectedProfile?.resolvers - ?.lineSequence() - ?.map { it.trim() } - ?.count { it.isNotEmpty() } - ?.coerceAtLeast(1) - ?: 1 + val profileResolversCount = remember(selectedProfile?.id, selectedProfile?.resolvers, selectedProfile?.resolverStatsJson) { + val selected = selectedProfile + val fileStatsCount = selected + ?.takeIf { it.resolverSourceType == "FILE" } + ?.let { ResolverAnalyzer.statsFromJson(it.resolverStatsJson)?.uniqueUsableIps } + ?.takeIf { it > 0 } + fileStatsCount + ?: selected?.resolvers + ?.lineSequence() + ?.map { it.trim() } + ?.count { it.isNotEmpty() } + ?.coerceAtLeast(1) + ?: 1 + } val totalResolvers = scanStatus.scanTotalFromCore.takeIf { it > 0 } ?: profileResolversCount val scannedCount = (scanStatus.validCount + scanStatus.rejectedCount).coerceAtMost(totalResolvers) val scanProgress = (scannedCount.toFloat() / totalResolvers.toFloat()).coerceIn(0f, 1f) + val scanEtaText = estimateScanEta( + scannedCount = scannedCount, + totalResolvers = totalResolvers, + startedAtMs = scanStatus.scanStartedAtMs, + updatedAtMs = scanStatus.scanUpdatedAtMs + ) val statusColor by animateColorAsState( targetValue = when (vpnState) { @@ -228,6 +243,7 @@ fun HomeScreen( scannedCount = scannedCount, totalResolvers = totalResolvers, scanProgress = scanProgress, + scanEtaText = scanEtaText, downBps = downBps, upBps = upBps, proxyHost = proxyHost, @@ -297,6 +313,7 @@ fun HomeScreen( scannedCount = scannedCount, totalResolvers = totalResolvers, scanProgress = scanProgress, + scanEtaText = scanEtaText, downBps = downBps, upBps = upBps, proxyHost = proxyHost, @@ -331,3 +348,24 @@ private fun parseAdvanced(json: String): Map { emptyMap() } } + +private fun estimateScanEta( + scannedCount: Int, + totalResolvers: Int, + startedAtMs: Long, + updatedAtMs: Long +): String { + if (scannedCount <= 0 || totalResolvers <= scannedCount || startedAtMs <= 0L || updatedAtMs <= startedAtMs) { + return "" + } + val elapsedMs = (updatedAtMs - startedAtMs).coerceAtLeast(1L) + val remaining = totalResolvers - scannedCount + val etaSeconds = ((elapsedMs / scannedCount.toDouble()) * remaining / 1000.0).toLong().coerceAtLeast(1L) + val minutes = etaSeconds / 60 + val seconds = etaSeconds % 60 + return if (minutes > 0) { + "${minutes}m ${seconds}s" + } else { + "${seconds}s" + } +} diff --git a/android/app/src/main/java/com/masterdns/vpn/ui/home/HomeStatusCards.kt b/android/app/src/main/java/com/masterdns/vpn/ui/home/HomeStatusCards.kt index 1548d7b..bc65acb 100644 --- a/android/app/src/main/java/com/masterdns/vpn/ui/home/HomeStatusCards.kt +++ b/android/app/src/main/java/com/masterdns/vpn/ui/home/HomeStatusCards.kt @@ -35,6 +35,7 @@ fun MdvConnectionTelemetryCard( scannedCount: Int, totalResolvers: Int, scanProgress: Float, + scanEtaText: String, downBps: Long, upBps: Long, proxyHost: String, @@ -99,6 +100,14 @@ fun MdvConnectionTelemetryCard( style = MaterialTheme.typography.bodySmall, color = MdvColor.OnSurfaceVariant ) + if (scanEtaText.isNotBlank()) { + androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(R.string.home_dns_scan_eta, scanEtaText), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + } androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(MdvSpace.S1)) LinearProgressIndicator( progress = { scanProgress }, diff --git a/android/app/src/main/java/com/masterdns/vpn/ui/profiles/ProfilesScreen.kt b/android/app/src/main/java/com/masterdns/vpn/ui/profiles/ProfilesScreen.kt index 840e42c..a81e9a5 100644 --- a/android/app/src/main/java/com/masterdns/vpn/ui/profiles/ProfilesScreen.kt +++ b/android/app/src/main/java/com/masterdns/vpn/ui/profiles/ProfilesScreen.kt @@ -24,12 +24,16 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.google.gson.Gson +import com.google.gson.reflect.TypeToken import com.masterdns.vpn.R import com.masterdns.vpn.data.local.ProfileEntity import com.masterdns.vpn.ui.components.mdv.controls.MdvBackTopAppBar import com.masterdns.vpn.ui.theme.ConnectedGreen import com.masterdns.vpn.ui.theme.MdvColor import com.masterdns.vpn.ui.theme.MdvSpace +import com.masterdns.vpn.util.ImportedResolverFile +import com.masterdns.vpn.util.ResolverAnalyzer +import com.masterdns.vpn.util.ResolverStats import kotlinx.coroutines.launch private data class ImportedProfileDraft( @@ -48,7 +52,7 @@ fun ProfilesScreen( var showEditor by remember { mutableStateOf(false) } var editingProfile by remember { mutableStateOf(null) } var importedDraft by remember { mutableStateOf(null) } - var importedResolvers by remember { mutableStateOf(null) } + var importedResolvers by remember { mutableStateOf(null) } val context = androidx.compose.ui.platform.LocalContext.current val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -74,15 +78,19 @@ fun ProfilesScreen( ActivityResultContracts.OpenDocument() ) { uri -> if (uri == null) return@rememberLauncherForActivityResult - val text = readTextFromUri(context, uri).trim() - if (text.isBlank()) { + val imported = ResolverAnalyzer.importUriToCache(context, uri, cidrEnabled = true) + if (imported == null) { scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.profiles_resolvers_empty_msg)) } return@rememberLauncherForActivityResult } - importedResolvers = text + importedResolvers = imported editingProfile = null showEditor = true - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.profiles_resolvers_imported_msg)) } + scope.launch { + snackbarHostState.showSnackbar( + context.getString(R.string.profiles_resolvers_imported_stats_msg, imported.stats.summary()) + ) + } } Scaffold( @@ -266,7 +274,7 @@ fun ProfileCard( private fun ProfileEditorDialog( profile: ProfileEntity?, importedDraft: ImportedProfileDraft?, - importedResolvers: String?, + importedResolvers: ImportedResolverFile?, onImportToml: () -> Unit, onImportResolvers: () -> Unit, onSave: (ProfileEntity) -> Unit, @@ -276,9 +284,19 @@ private fun ProfileEditorDialog( var domains by remember { mutableStateOf(profile?.domains?.removeSurrounding("[\"", "\"]").orEmpty()) } var encryptionKey by remember { mutableStateOf(profile?.encryptionKey.orEmpty()) } var resolvers by remember { mutableStateOf(profile?.resolvers ?: "8.8.8.8") } + var resolverFile by remember(profile?.id) { + mutableStateOf(profile?.toImportedResolverFile()) + } + var cidrEnabled by remember(profile?.id) { + mutableStateOf(profile?.advancedValue("RESOLVER_CIDR_ENABLED")?.toBooleanStrictOrNull() ?: true) + } + var resolverStats by remember(profile?.id) { + mutableStateOf(resolverFile?.stats ?: ResolverAnalyzer.analyzeText(resolvers, cidrEnabled = cidrEnabled)) + } var showKey by remember { mutableStateOf(false) } var showResolversEditor by remember { mutableStateOf(false) } - val largeResolversText = resolvers.length > 6000 + val usingResolverFile = resolverFile != null + val largeResolversText = !usingResolverFile && resolvers.length > 6000 LaunchedEffect(profile?.id) { if (profile != null) { @@ -286,6 +304,9 @@ private fun ProfileEditorDialog( domains = profile.domains.removeSurrounding("[\"", "\"]") encryptionKey = profile.encryptionKey resolvers = profile.resolvers + resolverFile = profile.toImportedResolverFile() + cidrEnabled = profile.advancedValue("RESOLVER_CIDR_ENABLED")?.toBooleanStrictOrNull() ?: true + resolverStats = resolverFile?.stats ?: ResolverAnalyzer.analyzeText(profile.resolvers, cidrEnabled = cidrEnabled) showResolversEditor = false } } @@ -301,8 +322,24 @@ private fun ProfileEditorDialog( encryptionKey = importedProfile.encryptionKey resolvers = importedProfile.resolvers } - if (!importedResolvers.isNullOrBlank()) { - resolvers = importedResolvers + if (importedResolvers != null) { + resolverFile = importedResolvers + resolverStats = importedResolvers.stats + resolvers = "" + } + } + + LaunchedEffect(resolvers, cidrEnabled, resolverFile?.cachedPath) { + resolverStats = resolverFile + ?.let { ResolverAnalyzer.analyzeCachedFile(it.cachedPath, it.displayName, cidrEnabled) ?: it.stats.copy(cidrEnabled = cidrEnabled) } + ?: ResolverAnalyzer.analyzeText(resolvers, cidrEnabled = cidrEnabled) + } + + LaunchedEffect(importedDraft?.profile?.advancedJson) { + val importedProfile = importedDraft?.profile ?: return@LaunchedEffect + cidrEnabled = importedProfile.advancedValue("RESOLVER_CIDR_ENABLED")?.toBooleanStrictOrNull() ?: cidrEnabled + if (importedProfile.resolverSourceType == "FILE") { + resolverFile = importedProfile.toImportedResolverFile() } } @@ -330,26 +367,13 @@ private fun ProfileEditorDialog( modifier = Modifier.fillMaxWidth() ) if (profile == null) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + OutlinedButton( + onClick = onImportToml, + modifier = Modifier.fillMaxWidth() ) { - OutlinedButton( - onClick = onImportToml, - modifier = Modifier.weight(1f) - ) { - Icon(Icons.Filled.UploadFile, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.action_import_toml)) - } - OutlinedButton( - onClick = onImportResolvers, - modifier = Modifier.weight(1f) - ) { - Icon(Icons.Filled.Description, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.profiles_import_resolvers_short)) - } + Icon(Icons.Filled.UploadFile, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.action_import_toml)) } } @@ -382,7 +406,63 @@ private fun ProfileEditorDialog( } ) - if (!showResolversEditor && largeResolversText) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MdvColor.SurfaceHigh) + ) { + Row( + modifier = Modifier.padding(10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.profiles_parse_cidr_title), fontWeight = FontWeight.SemiBold) + Text( + stringResource(R.string.profiles_parse_cidr_desc), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + } + Switch( + checked = cidrEnabled, + onCheckedChange = { cidrEnabled = it } + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = onImportResolvers, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.UploadFile, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.profiles_import_from_file)) + } + if (usingResolverFile) { + OutlinedButton( + onClick = { + resolverFile = null + resolvers = "8.8.8.8" + }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.Edit, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.profiles_use_inline_resolvers)) + } + } + } + + if (usingResolverFile) { + ResolverStatsCard( + title = stringResource(R.string.profiles_imported_resolver_file, resolverFile?.displayName.orEmpty()), + stats = resolverStats + ) + } else if (!showResolversEditor && largeResolversText) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MdvColor.SurfaceHigh) @@ -404,6 +484,8 @@ private fun ProfileEditorDialog( value = resolvers, onValueChange = { resolvers = it }, label = { Text(stringResource(R.string.profiles_resolvers_label)) }, + placeholder = { Text(stringResource(R.string.profiles_resolvers_placeholder)) }, + supportingText = { Text(stringResource(R.string.profiles_resolvers_stats_line, resolverStats.summary())) }, modifier = Modifier.fillMaxWidth().height(120.dp), maxLines = 6, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri) @@ -423,7 +505,12 @@ private fun ProfileEditorDialog( name = name.trim().ifEmpty { "Profile" }, domains = if (domainJson == "[]") gson.toJson(listOf(domains.trim())) else domainJson, encryptionKey = encryptionKey, - resolvers = resolvers.trim() + resolvers = if (resolverFile == null) resolvers.trim() else "", + resolverSourceType = if (resolverFile == null) "INLINE" else "FILE", + resolverFileName = resolverFile?.displayName.orEmpty(), + resolverCachedPath = resolverFile?.cachedPath.orEmpty(), + resolverStatsJson = ResolverAnalyzer.statsToJson(resolverStats.copy(cidrEnabled = cidrEnabled)), + advancedJson = advancedWithValue(baseProfile.advancedJson, "RESOLVER_CIDR_ENABLED", cidrEnabled.toString()) ) ) }, @@ -440,8 +527,77 @@ private fun ProfileEditorDialog( ) } +@Composable +private fun ResolverStatsCard(title: String, stats: ResolverStats?) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MdvColor.SurfaceHigh) + ) { + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text(title, fontWeight = FontWeight.SemiBold) + if (stats == null) { + Text( + stringResource(R.string.profiles_resolver_stats_unavailable), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + } else { + Text( + stats.summary(), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + Text( + stringResource( + R.string.profiles_resolver_stats_detail, + stats.rawLines, + stats.blankLines, + stats.commentLines, + stats.customPorts, + stats.skippedCidrs + ), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + } + } + } +} + private val gson = Gson() +private fun ProfileEntity.toImportedResolverFile(): ImportedResolverFile? { + if (resolverSourceType != "FILE" || resolverCachedPath.isBlank()) return null + val stats = ResolverAnalyzer.statsFromJson(resolverStatsJson) + ?: ResolverAnalyzer.analyzeCachedFile(resolverCachedPath, resolverFileName) + ?: ResolverStats(fileName = resolverFileName) + return ImportedResolverFile( + displayName = resolverFileName.ifBlank { stats.fileName.ifBlank { "client_resolvers.txt" } }, + cachedPath = resolverCachedPath, + stats = stats + ) +} + +private fun ProfileEntity.advancedValue(key: String): String? = parseAdvancedMap(advancedJson)[key] + +private fun advancedWithValue(json: String, key: String, value: String): String { + val advanced = parseAdvancedMap(json).toMutableMap() + advanced[key] = value + return gson.toJson(advanced) +} + +private fun parseAdvancedMap(json: String): Map { + return try { + val type = object : TypeToken>() {}.type + gson.fromJson>(json, type) ?: emptyMap() + } catch (_: Exception) { + emptyMap() + } +} + private fun readTextFromUri(context: Context, uri: Uri): String { return context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }.orEmpty() } diff --git a/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsScreen.kt index 9b2c277..c7a78f7 100644 --- a/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsScreen.kt @@ -72,6 +72,7 @@ import com.masterdns.vpn.ui.components.mdv.controls.MdvBackTopAppBar import com.masterdns.vpn.ui.components.mdv.controls.MdvTopAppBar import com.masterdns.vpn.ui.theme.MdvColor import com.masterdns.vpn.ui.theme.MdvSpace +import com.masterdns.vpn.util.ResolverAnalyzer import kotlinx.coroutines.launch private enum class FieldType { TEXT, BOOL, OPTION } @@ -156,6 +157,7 @@ private val configFields = listOf( SettingField("Resolver", "RECHECK_INACTIVE_SERVERS_ENABLED", "RECHECK_INACTIVE_SERVERS_ENABLED", "Re-check inactive resolvers", type = FieldType.BOOL), SettingField("Resolver", "AUTO_DISABLE_TIMEOUT_SERVERS", "AUTO_DISABLE_TIMEOUT_SERVERS", "Auto disable timeout resolvers", type = FieldType.BOOL), SettingField("Resolver", "AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS", "AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS", "Timeout-only window seconds", keyboardType = KeyboardType.Decimal), + SettingField("Resolver", "RESOLVER_CIDR_ENABLED", "RESOLVER_CIDR_ENABLED", "Parse and expand CIDR resolver ranges", type = FieldType.BOOL), SettingField("Resolver", "BASE_ENCODE_DATA", "BASE_ENCODE_DATA", "Use base-encoded payloads", type = FieldType.BOOL), SettingField("Compression", "UPLOAD_COMPRESSION_TYPE", "UPLOAD_COMPRESSION_TYPE", "0=Off, 1=Snappy, 2=LZ4, 3=ZSTD, 4=Gzip, 5=Zlib", keyboardType = KeyboardType.Number), SettingField("Compression", "DOWNLOAD_COMPRESSION_TYPE", "DOWNLOAD_COMPRESSION_TYPE", "0=Off, 1=Snappy, 2=LZ4, 3=ZSTD, 4=Gzip, 5=Zlib", keyboardType = KeyboardType.Number), @@ -270,9 +272,18 @@ fun SettingsScreen( ) { uri -> val selected = profile if (uri != null && selected != null) { - val text = readTextFromUri(context, uri) - viewModel.importResolvers(selected, text) - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.settings_resolvers_imported_msg)) } + val cidrEnabled = fieldsState["RESOLVER_CIDR_ENABLED"]?.toBooleanStrictOrNull() ?: true + val imported = ResolverAnalyzer.importUriToCache(context, uri, cidrEnabled) + if (imported == null) { + scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.profiles_resolvers_empty_msg)) } + return@rememberLauncherForActivityResult + } + viewModel.importResolvers(selected, imported, cidrEnabled) + scope.launch { + snackbarHostState.showSnackbar( + context.getString(R.string.settings_resolvers_imported_stats_msg, imported.stats.summary()) + ) + } } } val pickMtuExportLauncher = rememberLauncherForActivityResult( @@ -420,9 +431,45 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(MdvSpace.S1)) MdvPrimaryActionButton( text = stringResource(R.string.action_import_resolvers), - onClick = { importResolversLauncher.launch(arrayOf("text/*")) }, + onClick = { + importResolversLauncher.launch( + arrayOf( + "text/plain", + "application/octet-stream", + "*/*" + ) + ) + }, icon = Icons.Filled.UploadFile ) + if (selected.resolverSourceType == "FILE") { + val stats = ResolverAnalyzer.statsFromJson(selected.resolverStatsJson) + Spacer(modifier = Modifier.height(MdvSpace.S1)) + Card( + colors = CardDefaults.cardColors(containerColor = MdvColor.SurfaceHigh), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource( + R.string.profiles_imported_resolver_file, + selected.resolverFileName.ifBlank { "client_resolvers.txt" } + ), + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + color = MdvColor.OnSurface + ) + Text( + text = stats?.summary() + ?: stringResource(R.string.profiles_resolver_stats_unavailable), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + } + } + } Spacer(modifier = Modifier.height(MdvSpace.S1)) MdvPrimaryActionButton( text = stringResource(R.string.action_pick_mtu_destination), @@ -469,6 +516,25 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(MdvSpace.S2)) } } + if (section == "MTU") { + val parallelism = fieldsState["MTU_TEST_PARALLELISM"]?.toIntOrNull() ?: 16 + if (parallelism > 100) { + Card( + colors = CardDefaults.cardColors( + containerColor = MdvColor.ErrorContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.settings_mtu_parallelism_warning, parallelism), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.Error, + modifier = Modifier.padding(12.dp) + ) + } + Spacer(modifier = Modifier.height(MdvSpace.S2)) + } + } sections[section].orEmpty().forEach { field -> if ((field.key == "SOCKS5_USER" || field.key == "SOCKS5_PASS") && !socksAuthEnabled) { return@forEach @@ -634,6 +700,7 @@ private fun defaultValuesFor(profile: ProfileEntity): Map { put("RECHECK_INACTIVE_SERVERS_ENABLED", adv("RECHECK_INACTIVE_SERVERS_ENABLED", "true")) put("AUTO_DISABLE_TIMEOUT_SERVERS", adv("AUTO_DISABLE_TIMEOUT_SERVERS", "true")) put("AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS", adv("AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS", "30.0")) + put("RESOLVER_CIDR_ENABLED", adv("RESOLVER_CIDR_ENABLED", "true")) put("BASE_ENCODE_DATA", adv("BASE_ENCODE_DATA", "false")) put("UPLOAD_COMPRESSION_TYPE", profile.uploadCompression.toString()) put("DOWNLOAD_COMPRESSION_TYPE", profile.downloadCompression.toString()) diff --git a/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsViewModel.kt index f0b8934..848d80e 100644 --- a/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsViewModel.kt +++ b/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsViewModel.kt @@ -7,7 +7,9 @@ import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.masterdns.vpn.data.local.ProfileEntity import com.masterdns.vpn.data.repository.ProfileRepository +import com.masterdns.vpn.util.ImportedResolverFile import com.masterdns.vpn.util.ConfigGenerator +import com.masterdns.vpn.util.ResolverAnalyzer import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -73,9 +75,20 @@ class SettingsViewModel @Inject constructor( return result } - fun importResolvers(profile: ProfileEntity, resolversText: String) { + fun importResolvers(profile: ProfileEntity, imported: ImportedResolverFile, cidrEnabled: Boolean) { viewModelScope.launch { - profileRepository.updateProfile(profile.copy(resolvers = resolversText.trim())) + val advanced = parseAdvanced(profile.advancedJson).toMutableMap() + advanced["RESOLVER_CIDR_ENABLED"] = cidrEnabled.toString() + profileRepository.updateProfile( + profile.copy( + resolvers = "", + resolverSourceType = "FILE", + resolverFileName = imported.displayName, + resolverCachedPath = imported.cachedPath, + resolverStatsJson = ResolverAnalyzer.statsToJson(imported.stats.copy(cidrEnabled = cidrEnabled)), + advancedJson = gson.toJson(advanced) + ) + ) } } @@ -175,6 +188,7 @@ class SettingsViewModel @Inject constructor( "RECHECK_INACTIVE_SERVERS_ENABLED", "AUTO_DISABLE_TIMEOUT_SERVERS", "AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS", + "RESOLVER_CIDR_ENABLED", "BASE_ENCODE_DATA", "UPLOAD_COMPRESSION_TYPE", "DOWNLOAD_COMPRESSION_TYPE", @@ -253,6 +267,7 @@ class SettingsViewModel @Inject constructor( "RECHECK_INACTIVE_SERVERS_ENABLED", "AUTO_DISABLE_TIMEOUT_SERVERS", "AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS", + "RESOLVER_CIDR_ENABLED", "BASE_ENCODE_DATA", "COMPRESSION_MIN_SIZE", "MIN_UPLOAD_MTU", diff --git a/android/app/src/main/java/com/masterdns/vpn/util/ConfigGenerator.kt b/android/app/src/main/java/com/masterdns/vpn/util/ConfigGenerator.kt index bfa1ed2..4841824 100644 --- a/android/app/src/main/java/com/masterdns/vpn/util/ConfigGenerator.kt +++ b/android/app/src/main/java/com/masterdns/vpn/util/ConfigGenerator.kt @@ -80,6 +80,7 @@ object ConfigGenerator { appendLine("RECHECK_INACTIVE_SERVERS_ENABLED = ${cfg("RECHECK_INACTIVE_SERVERS_ENABLED", "true")}") appendLine("AUTO_DISABLE_TIMEOUT_SERVERS = ${cfg("AUTO_DISABLE_TIMEOUT_SERVERS", "true")}") appendLine("AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS = ${cfg("AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS", "30.0")}") + appendLine("RESOLVER_CIDR_ENABLED = ${cfg("RESOLVER_CIDR_ENABLED", "true")}") appendLine("BASE_ENCODE_DATA = ${cfg("BASE_ENCODE_DATA", "false")}") appendLine() diff --git a/android/app/src/main/java/com/masterdns/vpn/util/ResolverAnalyzer.kt b/android/app/src/main/java/com/masterdns/vpn/util/ResolverAnalyzer.kt new file mode 100644 index 0000000..787a2c7 --- /dev/null +++ b/android/app/src/main/java/com/masterdns/vpn/util/ResolverAnalyzer.kt @@ -0,0 +1,264 @@ +package com.masterdns.vpn.util + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import com.google.gson.Gson +import java.io.File +import java.math.BigInteger +import java.net.InetAddress + +data class ResolverStats( + val fileName: String = "", + val rawLines: Int = 0, + val blankLines: Int = 0, + val commentLines: Int = 0, + val validIps: Int = 0, + val duplicateIps: Int = 0, + val invalidLines: Int = 0, + val cidrRanges: Int = 0, + val cidrExpandedIps: Int = 0, + val skippedCidrs: Int = 0, + val customPorts: Int = 0, + val defaultPorts: Int = 0, + val uniqueUsableIps: Int = 0, + val cidrEnabled: Boolean = true +) { + fun summary(): String { + return "IPs: $uniqueUsableIps Duplicates: $duplicateIps Invalid: $invalidLines CIDR: $cidrRanges -> $cidrExpandedIps IPs" + } +} + +object ResolverAnalyzer { + private const val DEFAULT_PORT = 53 + private const val MAX_CIDR_HOSTS = 65536 + private val gson = Gson() + + fun analyzeText( + text: String, + fileName: String = "", + cidrEnabled: Boolean = true + ): ResolverStats { + val seen = linkedSetOf() + var rawLines = 0 + var blankLines = 0 + var commentLines = 0 + var validIps = 0 + var duplicateIps = 0 + var invalidLines = 0 + var cidrRanges = 0 + var cidrExpandedIps = 0 + var skippedCidrs = 0 + var customPorts = 0 + var defaultPorts = 0 + + text.lineSequence().forEach { raw -> + rawLines++ + val line = raw.substringBefore("#").trim() + when { + raw.trim().isEmpty() -> { + blankLines++ + return@forEach + } + line.isEmpty() -> { + commentLines++ + return@forEach + } + } + + val entry = parseEntry(line) + if (entry == null) { + invalidLines++ + return@forEach + } + + if (entry.host.contains("/")) { + cidrRanges++ + if (!cidrEnabled) { + skippedCidrs++ + return@forEach + } + val hosts = expandCidr(entry.host) + if (hosts == null) { + invalidLines++ + return@forEach + } + if (hosts.isEmpty()) { + skippedCidrs++ + return@forEach + } + hosts.forEach { ip -> + cidrExpandedIps++ + if (!seen.add(ip)) duplicateIps++ else validIps++ + } + } else { + val ip = parseIp(entry.host) + if (ip == null) { + invalidLines++ + return@forEach + } + if (!seen.add(ip)) duplicateIps++ else validIps++ + } + + if (entry.port == DEFAULT_PORT && !entry.hasExplicitPort) { + defaultPorts++ + } else { + customPorts++ + } + } + + return ResolverStats( + fileName = fileName, + rawLines = rawLines, + blankLines = blankLines, + commentLines = commentLines, + validIps = validIps, + duplicateIps = duplicateIps, + invalidLines = invalidLines, + cidrRanges = cidrRanges, + cidrExpandedIps = cidrExpandedIps, + skippedCidrs = skippedCidrs, + customPorts = customPorts, + defaultPorts = defaultPorts, + uniqueUsableIps = seen.size, + cidrEnabled = cidrEnabled + ) + } + + fun statsToJson(stats: ResolverStats): String = gson.toJson(stats) + + fun statsFromJson(json: String): ResolverStats? { + return runCatching { gson.fromJson(json, ResolverStats::class.java) }.getOrNull() + } + + fun readDisplayName(context: Context, uri: Uri): String? { + return context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex < 0 || !cursor.moveToFirst()) return@use null + cursor.getString(nameIndex) + }?.trim()?.takeIf { it.isNotEmpty() } + } + + fun importUriToCache(context: Context, uri: Uri, cidrEnabled: Boolean): ImportedResolverFile? { + val name = readDisplayName(context, uri) ?: "client_resolvers.txt" + val text = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }.orEmpty() + if (text.isBlank()) return null + + val resolverDir = File(context.filesDir, "resolver_sources").apply { mkdirs() } + val safeName = name.replace(Regex("[^A-Za-z0-9._-]"), "_").ifBlank { "client_resolvers.txt" } + val outFile = File(resolverDir, "${System.currentTimeMillis()}_$safeName") + outFile.writeText(text) + + val stats = analyzeText(text, name, cidrEnabled) + return ImportedResolverFile( + displayName = name, + cachedPath = outFile.absolutePath, + stats = stats + ) + } + + fun analyzeCachedFile(path: String, fileName: String = "", cidrEnabled: Boolean = true): ResolverStats? { + val file = File(path) + if (!file.isFile) return null + return analyzeText(file.readText(), fileName, cidrEnabled) + } + + private data class Entry( + val host: String, + val port: Int, + val hasExplicitPort: Boolean + ) + + private fun parseEntry(line: String): Entry? { + val text = line.trim() + if (text.isEmpty()) return null + + if (text.startsWith("[")) { + val end = text.indexOf(']') + if (end <= 0) return null + val host = text.substring(1, end).trim() + val remainder = text.substring(end + 1).trim() + if (remainder.isEmpty()) return Entry(host, DEFAULT_PORT, false) + if (!remainder.startsWith(":")) return null + val port = remainder.drop(1).trim().toIntOrNull() ?: return null + return if (port in 1..65535) Entry(host, port, true) else null + } + + val slashBeforeColon = text.contains("/") && text.lastIndexOf(':') > text.lastIndexOf('/') + val hasSingleColon = text.count { it == ':' } == 1 + val canHavePort = hasSingleColon || slashBeforeColon + if (canHavePort) { + val idx = text.lastIndexOf(':') + val host = text.substring(0, idx).trim() + val port = text.substring(idx + 1).trim().toIntOrNull() + if (host.isNotEmpty() && port != null && port in 1..65535) { + return Entry(host, port, true) + } + } + + return Entry(text, DEFAULT_PORT, false) + } + + private fun parseIp(host: String): String? { + val text = host.trim() + val numericCandidate = when { + "." in text && ":" !in text -> text.matches(Regex("\\d{1,3}(\\.\\d{1,3}){3}")) + ":" in text -> text.matches(Regex("[0-9A-Fa-f:.]+")) + else -> false + } + if (!numericCandidate) return null + return runCatching { + val address = InetAddress.getByName(text) + address.hostAddress + }.getOrNull() + } + + private fun expandCidr(value: String): List? { + val parts = value.split("/") + if (parts.size != 2) return null + val normalizedBase = parseIp(parts[0].trim()) ?: return null + val base = runCatching { InetAddress.getByName(normalizedBase) }.getOrNull() ?: return null + val bytes = base.address + val totalBits = bytes.size * 8 + val prefixBits = parts[1].trim().toIntOrNull() ?: return null + if (prefixBits !in 0..totalBits) return null + val hostBits = totalBits - prefixBits + if (hostBits > 16) return emptyList() + + val total = BigInteger.ONE.shiftLeft(hostBits) + val usableStart = if (bytes.size == 4 && prefixBits < 31) BigInteger.ONE else BigInteger.ZERO + val usableEndExclusive = if (bytes.size == 4 && prefixBits < 31) total.subtract(BigInteger.ONE) else total + val usableCount = usableEndExclusive.subtract(usableStart) + if (usableCount <= BigInteger.ZERO || usableCount > BigInteger.valueOf(MAX_CIDR_HOSTS.toLong())) { + return emptyList() + } + + val baseInt = BigInteger(1, bytes) + val mask = BigInteger.ONE.shiftLeft(totalBits).subtract(BigInteger.ONE).xor( + BigInteger.ONE.shiftLeft(hostBits).subtract(BigInteger.ONE) + ) + val network = baseInt.and(mask) + val result = ArrayList(usableCount.toInt()) + var offset = usableStart + while (offset < usableEndExclusive) { + result += addressFromBigInteger(network.add(offset), bytes.size).hostAddress + offset = offset.add(BigInteger.ONE) + } + return result + } + + private fun addressFromBigInteger(value: BigInteger, byteCount: Int): InetAddress { + val raw = value.toByteArray() + val out = ByteArray(byteCount) + val copyStart = maxOf(0, raw.size - byteCount) + val copyLength = raw.size - copyStart + System.arraycopy(raw, copyStart, out, byteCount - copyLength, copyLength) + return InetAddress.getByAddress(out) + } +} + +data class ImportedResolverFile( + val displayName: String, + val cachedPath: String, + val stats: ResolverStats +) diff --git a/android/app/src/main/java/com/masterdns/vpn/util/VpnManager.kt b/android/app/src/main/java/com/masterdns/vpn/util/VpnManager.kt index 3d42678..317c379 100644 --- a/android/app/src/main/java/com/masterdns/vpn/util/VpnManager.kt +++ b/android/app/src/main/java/com/masterdns/vpn/util/VpnManager.kt @@ -61,7 +61,9 @@ object VpnManager { val scanTotalFromCore: Int = 0, val activeResolvers: Int = 0, val syncedUploadMtu: Int = 0, - val syncedDownloadMtu: Int = 0 + val syncedDownloadMtu: Int = 0, + val scanStartedAtMs: Long = 0L, + val scanUpdatedAtMs: Long = 0L ) private val _scanStatus = MutableStateFlow(ScanStatus()) @@ -156,7 +158,12 @@ object VpnManager { updateState(VpnState.CONNECTING) clearError() - _scanStatus.value = ScanStatus(scanning = true) + val now = System.currentTimeMillis() + _scanStatus.value = ScanStatus( + scanning = true, + scanStartedAtMs = now, + scanUpdatedAtMs = now + ) val intent = Intent(context, MasterDnsVpnService::class.java).apply { action = MasterDnsVpnService.ACTION_CONNECT @@ -192,6 +199,12 @@ object VpnManager { } private fun parseScanLine(line: String) { + fun currentScanTimestamps(): Pair { + val now = System.currentTimeMillis() + val started = _scanStatus.value.scanStartedAtMs.takeIf { it > 0L } ?: now + return started to now + } + val indexedProgressMatch = Regex( "(?:scan|scanning|resolver|resolvers|mtu).{0,40}?(\\d+)\\s*/\\s*(\\d+)", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL) @@ -199,7 +212,12 @@ object VpnManager { if (indexedProgressMatch != null) { val total = indexedProgressMatch.groupValues[2].toIntOrNull() if (total != null && total > 0) { - _scanStatus.value = _scanStatus.value.copy(scanTotalFromCore = total) + val (started, updated) = currentScanTimestamps() + _scanStatus.value = _scanStatus.value.copy( + scanTotalFromCore = total, + scanStartedAtMs = started, + scanUpdatedAtMs = updated + ) } } @@ -210,7 +228,12 @@ object VpnManager { if (totalCandidatesMatch != null) { val total = totalCandidatesMatch.groupValues[1].toIntOrNull() if (total != null && total > 0) { - _scanStatus.value = _scanStatus.value.copy(scanTotalFromCore = total) + val (started, updated) = currentScanTimestamps() + _scanStatus.value = _scanStatus.value.copy( + scanTotalFromCore = total, + scanStartedAtMs = started, + scanUpdatedAtMs = updated + ) } } @@ -227,12 +250,15 @@ object VpnManager { line.contains("Rejected", ignoreCase = true) -> "Rejected" else -> "" } + val (started, updated) = currentScanTimestamps() _scanStatus.value = _scanStatus.value.copy( scanning = true, lastResolver = resolver, lastDecision = decision, validCount = valid, - rejectedCount = rejected + rejectedCount = rejected, + scanStartedAtMs = started, + scanUpdatedAtMs = updated ) return } @@ -283,7 +309,12 @@ object VpnManager { } if (line.contains("Testing MTU sizes", ignoreCase = true)) { - _scanStatus.value = _scanStatus.value.copy(scanning = true) + val (started, updated) = currentScanTimestamps() + _scanStatus.value = _scanStatus.value.copy( + scanning = true, + scanStartedAtMs = started, + scanUpdatedAtMs = updated + ) return } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 8ae8546..8709786 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -8,7 +8,7 @@ Show Import TOML Export TOML - Import client_resolvers.txt + Import resolver file Pick MTU export destination Save Settings No selected profile @@ -17,8 +17,10 @@ Profile settings saved and applied TOML exported TOML imported to form - Resolvers imported into profile + Resolver file imported into profile + Resolver file imported: %1$s MTU export destination selected + MTU_TEST_PARALLELISM is %1$d. This is allowed, but very high values can increase CPU, memory, and packet loss. ⚠ Port %1$d requires root access on Android. The app will automatically use port 5353 instead. Global settings saved and applied Please enter both SOCKS5 and HTTP ports. @@ -61,6 +63,7 @@ Valid: Rejected: DNS Scan Progress: %1$d / %2$d + ETA: %1$s Synced MTU: UP %1$d / DOWN %2$d Active Resolvers: %1$d Download: %1$s Upload: %2$s @@ -90,11 +93,21 @@ Resolvers list is large (%1$d lines) To avoid UI lag, tap Edit to open the text box. Edit Resolvers - Resolvers (one per line) + Resolvers: IP, IP:port, CIDR, CIDR:port + 8.8.8.8 1.1.1.1:53 9.9.9.0/30 [2001:4860:4860::8888]:53 + Stats: %1$s + Parse CIDR ranges + When enabled, CIDR lines are expanded before the runtime loads resolvers. + Import from file + Use text input + Resolver file: %1$s + Resolver stats unavailable + Lines: %1$d, blank: %2$d, comments: %3$d, custom ports: %4$d, skipped CIDR: %5$d Invalid TOML: DOMAIN/ENCRYPTION_KEY not found TOML imported into profile form Resolvers file is empty - Resolvers imported into profile form + Resolver file imported into profile form + Resolver file imported: %1$s Imported Profile Share Logs Auto diff --git a/internal/client/client.go b/internal/client/client.go index 803ca81..3ee63ef 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -64,6 +64,7 @@ type Client struct { mtuSuccessOutputPath string mtuOutputMu sync.Mutex mtuUsageSeparatorWritten bool + mtuSessionWorkingResolvers map[string]struct{} mtuUsingSeparatorText string mtuRemovedServerLogFormat string mtuAddedServerLogFormat string @@ -266,6 +267,7 @@ func New(cfg config.ClientConfig, log *logger.Logger, codec *security.Codec) *Cl mtuSaveToFile: cfg.SaveMTUServersToFile, mtuServersFileName: cfg.MTUServersFileName, mtuServersFileFormat: cfg.MTUServersFileFormat, + mtuSessionWorkingResolvers: make(map[string]struct{}), mtuUsingSeparatorText: cfg.MTUUsingSeparatorText, mtuRemovedServerLogFormat: cfg.MTURemovedServerLogFormat, mtuAddedServerLogFormat: cfg.MTUAddedServerLogFormat, diff --git a/internal/client/mtu_logging.go b/internal/client/mtu_logging.go index c97cbaa..4a67266 100644 --- a/internal/client/mtu_logging.go +++ b/internal/client/mtu_logging.go @@ -203,6 +203,7 @@ func (c *Client) prepareMTUSuccessOutputFile() string { outputPath := c.resolveMTUSuccessOutputPath() c.mtuOutputMu.Lock() c.mtuUsageSeparatorWritten = false + c.mtuSessionWorkingResolvers = make(map[string]struct{}) c.mtuSuccessOutputPath = "" c.mtuOutputMu.Unlock() if outputPath == "" { @@ -348,6 +349,24 @@ func (c *Client) appendMTUSuccessLine(conn *Connection) { if c == nil { return } + resolverKey := strings.TrimSpace(conn.ResolverLabel) + if resolverKey == "" { + resolverKey = strings.TrimSpace(conn.Resolver) + } + if resolverKey == "" { + return + } + c.mtuOutputMu.Lock() + if c.mtuSessionWorkingResolvers == nil { + c.mtuSessionWorkingResolvers = make(map[string]struct{}) + } + if _, exists := c.mtuSessionWorkingResolvers[resolverKey]; exists { + c.mtuOutputMu.Unlock() + return + } + c.mtuSessionWorkingResolvers[resolverKey] = struct{}{} + c.mtuOutputMu.Unlock() + template := c.mtuServersFileFormat if template == "" { template = defaultMTUServersFileFormat diff --git a/internal/client/mtu_logging_test.go b/internal/client/mtu_logging_test.go index bd39375..906f9a4 100644 --- a/internal/client/mtu_logging_test.go +++ b/internal/client/mtu_logging_test.go @@ -61,3 +61,42 @@ func TestAppendMTURemovedServerLineWritesConfiguredFormat(t *testing.T) { t.Fatalf("unexpected logged line: %q", line) } } + +func TestAppendMTUSuccessLineWritesEachResolverOncePerSession(t *testing.T) { + dir := t.TempDir() + outputPath := filepath.Join(dir, "mtu.log") + + c := buildTestClientWithResolvers(config.ClientConfig{}, "a") + c.mtuSaveToFile = true + c.mtuSuccessOutputPath = outputPath + c.mtuServersFileFormat = "{IP} ({DOMAIN})" + c.mtuSessionWorkingResolvers = make(map[string]struct{}) + + c.appendMTUSuccessLine(&Connection{ + ResolverLabel: "1.1.1.1:53", + Domain: "first.example.com", + }) + c.appendMTUSuccessLine(&Connection{ + ResolverLabel: "1.1.1.1:53", + Domain: "second.example.com", + }) + c.appendMTUSuccessLine(&Connection{ + ResolverLabel: "8.8.8.8:53", + Domain: "third.example.com", + }) + + raw, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(raw)), "\n") + if len(lines) != 2 { + t.Fatalf("expected one line per working resolver, got %d lines: %q", len(lines), string(raw)) + } + if !strings.Contains(lines[0], "1.1.1.1:53") || strings.Contains(string(raw), "second.example.com") { + t.Fatalf("duplicate resolver should not be exported again: %q", string(raw)) + } + if !strings.Contains(lines[1], "8.8.8.8:53") { + t.Fatalf("expected second resolver to be exported: %q", string(raw)) + } +} diff --git a/internal/config/client.go b/internal/config/client.go index 72ee07d..5716540 100644 --- a/internal/config/client.go +++ b/internal/config/client.go @@ -52,6 +52,7 @@ type ClientConfig struct { RecheckInactiveServersEnabled bool `toml:"RECHECK_INACTIVE_SERVERS_ENABLED"` AutoDisableTimeoutServers bool `toml:"AUTO_DISABLE_TIMEOUT_SERVERS"` AutoDisableTimeoutWindowSeconds float64 `toml:"AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS"` + ResolverCIDREnabled bool `toml:"RESOLVER_CIDR_ENABLED"` BaseEncodeData bool `toml:"BASE_ENCODE_DATA"` UploadCompressionType int `toml:"UPLOAD_COMPRESSION_TYPE"` DownloadCompressionType int `toml:"DOWNLOAD_COMPRESSION_TYPE"` @@ -153,6 +154,7 @@ func defaultClientConfig() ClientConfig { RecheckInactiveServersEnabled: true, AutoDisableTimeoutServers: true, AutoDisableTimeoutWindowSeconds: 30.0, + ResolverCIDREnabled: true, BaseEncodeData: false, UploadCompressionType: compression.TypeOff, DownloadCompressionType: compression.TypeOff, @@ -477,7 +479,9 @@ func finalizeClientConfig(cfg ClientConfig) (ClientConfig, error) { cfg.ResolversFilePath = strings.TrimSpace(cfg.ResolversFilePath) - resolvers, resolverMap, err := LoadClientResolvers(cfg.ResolversPath()) + resolvers, resolverMap, err := LoadClientResolversWithOptions(cfg.ResolversPath(), ResolverLoadOptions{ + CIDREnabled: cfg.ResolverCIDREnabled, + }) if err != nil { return cfg, err } diff --git a/internal/config/client_resolvers.go b/internal/config/client_resolvers.go index 4f6c53e..9a0cc43 100644 --- a/internal/config/client_resolvers.go +++ b/internal/config/client_resolvers.go @@ -28,6 +28,10 @@ type ResolverAddress struct { Port int } +type ResolverLoadOptions struct { + CIDREnabled bool +} + type resolverTarget struct { addr netip.Addr prefix netip.Prefix @@ -35,6 +39,10 @@ type resolverTarget struct { } func LoadClientResolvers(filename string) ([]ResolverAddress, map[string]int, error) { + return LoadClientResolversWithOptions(filename, ResolverLoadOptions{CIDREnabled: true}) +} + +func LoadClientResolversWithOptions(filename string, options ResolverLoadOptions) ([]ResolverAddress, map[string]int, error) { path, err := filepath.Abs(filename) if err != nil { return nil, nil, err @@ -68,6 +76,9 @@ func LoadClientResolvers(filename string) ([]ResolverAddress, map[string]int, er addResolver(&endpoints, resolverMap, seenIPs, target.addr.String(), port) continue } + if !options.CIDREnabled { + continue + } usableHosts, ok := usableHostCount(target.prefix) if !ok || usableHosts > maxResolverHosts { diff --git a/internal/config/client_resolvers_test.go b/internal/config/client_resolvers_test.go index 907a1a7..0ade066 100644 --- a/internal/config/client_resolvers_test.go +++ b/internal/config/client_resolvers_test.go @@ -62,6 +62,34 @@ func TestLoadClientResolversRejectsHugeCIDR(t *testing.T) { } } +func TestLoadClientResolversCanDisableCIDRExpansion(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "client_resolvers.txt") + + content := ` +192.168.10.0/30 +8.8.8.8 +` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + resolvers, resolverMap, err := LoadClientResolversWithOptions(path, ResolverLoadOptions{CIDREnabled: false}) + if err != nil { + t.Fatalf("LoadClientResolversWithOptions returned error: %v", err) + } + + if len(resolvers) != 1 { + t.Fatalf("unexpected resolver count: got=%d want=%d", len(resolvers), 1) + } + if resolverMap["8.8.8.8"] != 53 { + t.Fatalf("unexpected default resolver map: %+v", resolverMap) + } + if _, ok := resolverMap["192.168.10.1"]; ok { + t.Fatal("CIDR entry should not be expanded when disabled") + } +} + func TestLoadClientResolversDropsDuplicateIPsEvenWithDifferentPorts(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "client_resolvers.txt")