Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.masterdns.vpn.data.local

import android.util.Base64
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec

/**
* Encrypts and decrypts identity fields for locked profile export.
* This deters casual disclosure in shared TOML files; it is not a DRM boundary.
*/
object IdentityCipher {
private const val PREFIX = "ENC:"
private const val GCM_TAG_BITS = 128
private const val IV_BYTES = 12

private val keyBytes: ByteArray by lazy {
val a = byteArrayOf(
0x4D, 0x61, 0x73, 0x74, 0x65, 0x72, 0x44, 0x6E,
0x73, 0x56, 0x50, 0x4E, 0x2D, 0x47, 0x47, 0x2D
)
val b = byteArrayOf(
0x4C, 0x6F, 0x63, 0x6B, 0x65, 0x64, 0x49, 0x64,
0x65, 0x6E, 0x74, 0x69, 0x74, 0x79, 0x4B, 0x31
)
a + b
}

private fun keySpec() = SecretKeySpec(keyBytes, "AES")

fun encrypt(plaintext: String): String {
val iv = ByteArray(IV_BYTES).also { SecureRandom().nextBytes(it) }
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, keySpec(), GCMParameterSpec(GCM_TAG_BITS, iv))
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
return PREFIX + Base64.encodeToString(iv + ciphertext, Base64.NO_WRAP)
}

fun decrypt(encoded: String): String? {
if (!isEncrypted(encoded)) return null
return runCatching {
val combined = Base64.decode(encoded.removePrefix(PREFIX), Base64.NO_WRAP)
if (combined.size <= IV_BYTES) return null
val iv = combined.copyOfRange(0, IV_BYTES)
val ciphertext = combined.copyOfRange(IV_BYTES, combined.size)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, keySpec(), GCMParameterSpec(GCM_TAG_BITS, iv))
String(cipher.doFinal(ciphertext), Charsets.UTF_8)
}.getOrNull()
}

fun isEncrypted(value: String): Boolean = value.startsWith(PREFIX)
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.masterdns.vpn.data.repository

import com.masterdns.vpn.data.local.IdentityCipher
import com.masterdns.vpn.data.local.ProfileDao
import com.masterdns.vpn.data.local.ProfileEntity
import com.masterdns.vpn.util.ConfigGenerator
import com.google.gson.Gson
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
Expand All @@ -10,6 +13,8 @@ import javax.inject.Singleton
class ProfileRepository @Inject constructor(
private val profileDao: ProfileDao
) {
private val gson = Gson()

fun getAllProfiles(): Flow<List<ProfileEntity>> = profileDao.getAllProfiles()

fun getSelectedProfileFlow(): Flow<ProfileEntity?> = profileDao.getSelectedProfileFlow()
Expand All @@ -26,4 +31,155 @@ class ProfileRepository @Inject constructor(
suspend fun deleteProfile(profile: ProfileEntity) = profileDao.deleteProfile(profile)

suspend fun setSelectedProfile(id: Long) = profileDao.setSelectedProfile(id)

/**
* Export a profile as a TOML string.
* If [lockIdentity] is true, DOMAINS and ENCRYPTION_KEY are AES-256-GCM encrypted
* so the recipient can use the profile without seeing the actual values.
*/
suspend fun exportProfileToml(id: Long, lockIdentity: Boolean = false): String? {
val profile = profileDao.getProfileById(id) ?: return null
return ConfigGenerator.exportToml(profile, lockIdentity)
}

/**
* Import a profile from a TOML string.
* If IDENTITY_LOCKED = true in the TOML, domains and key are decrypted before storage.
*/
suspend fun importProfileFromToml(tomlContent: String, name: String): ProfileEntity? {
val profile = parseTomlToProfile(tomlContent, name) ?: return null
val id = profileDao.insertProfile(profile)
return profile.copy(id = id)
}

fun previewProfileFromToml(tomlContent: String, name: String): ProfileEntity? {
return parseTomlToProfile(tomlContent, name)
}

private fun parseTomlToProfile(tomlContent: String, name: String): ProfileEntity? {
val values = mutableMapOf<String, String>()
tomlContent.lineSequence().forEach { raw ->
val line = raw.substringBefore("#").trim()
if (line.isEmpty() || "=" !in line) return@forEach
val key = line.substringBefore("=").trim()
val valueRaw = line.substringAfter("=").trim()
val parsed = when {
key == "DOMAINS" -> {
if (valueRaw.startsWith("[")) {
// Array format: ["a.com", "b.com"]
valueRaw
.removePrefix("[").removeSuffix("]")
.split(",")
.map { it.trim().removeSurrounding("\"") }
.filter { it.isNotBlank() }
.joinToString(", ")
} else {
// Single string format: "ENC:..." or "value"
valueRaw.removeSurrounding("\"")
}
}
valueRaw.startsWith("\"") && valueRaw.endsWith("\"") ->
valueRaw.removeSurrounding("\"")
else -> valueRaw
}
values[key] = parsed
}

val isLocked = values["IDENTITY_LOCKED"]?.trim()?.equals("true", ignoreCase = true) == true

val rawDomains = values["DOMAINS"]?.takeIf { it.isNotBlank() } ?: return null
val rawKey = values["ENCRYPTION_KEY"]?.takeIf { it.isNotBlank() } ?: return null

val finalDomains: String
val finalKey: String

if (isLocked) {
// Try single-encrypted format first (new format: whole domains string encrypted at once)
val trimmedDomains = rawDomains.trim()
if (IdentityCipher.isEncrypted(trimmedDomains)) {
val decrypted = IdentityCipher.decrypt(trimmedDomains)
if (decrypted != null) {
finalDomains = gson.toJson(decrypted.split(",").map { it.trim() }.filter { it.isNotEmpty() })
} else {
// Decryption failed — do not store encrypted garbage
return null
}
} else {
// Legacy format: each domain encrypted separately
val decryptedDomains = rawDomains.split(",")
.map { it.trim() }
.map { d ->
if (IdentityCipher.isEncrypted(d)) {
IdentityCipher.decrypt(d) ?: return null // fail if any domain can't be decrypted
} else d
}
finalDomains = gson.toJson(decryptedDomains)
}
finalKey = if (IdentityCipher.isEncrypted(rawKey)) {
IdentityCipher.decrypt(rawKey) ?: return null
} else rawKey
} else {
finalDomains = gson.toJson(rawDomains.split(",").map { it.trim() }.filter { it.isNotEmpty() })
finalKey = rawKey
}

val advanced = mutableMapOf<String, String>()
IMPORT_ADVANCED_KEYS.forEach { key -> values[key]?.let { advanced[key] = it.trim() } }

return ProfileEntity(
name = name,
domains = finalDomains,
encryptionMethod = values["DATA_ENCRYPTION_METHOD"]?.toIntOrNull() ?: 1,
encryptionKey = finalKey,
protocolType = when (values["PROTOCOL_TYPE"]?.trim()?.uppercase()) { "TCP" -> "TCP" else -> "SOCKS5" },
listenPort = values["LISTEN_PORT"]?.toIntOrNull()?.coerceIn(1, 65535) ?: 18000,
resolverBalancingStrategy = values["RESOLVER_BALANCING_STRATEGY"]?.toIntOrNull() ?: 2,
packetDuplicationCount = values["PACKET_DUPLICATION_COUNT"]?.toIntOrNull() ?: 2,
setupPacketDuplicationCount = values["SETUP_PACKET_DUPLICATION_COUNT"]?.toIntOrNull() ?: 2,
uploadCompression = values["UPLOAD_COMPRESSION_TYPE"]?.toIntOrNull() ?: 0,
downloadCompression = values["DOWNLOAD_COMPRESSION_TYPE"]?.toIntOrNull() ?: 0,
logLevel = values["LOG_LEVEL"]?.trim().takeUnless { it.isNullOrBlank() } ?: "INFO",
resolvers = "8.8.8.8",
advancedJson = gson.toJson(advanced),
)
}

companion object {
private val IMPORT_ADVANCED_KEYS = setOf(
"LISTEN_IP", "SOCKS5_AUTH", "SOCKS5_USER", "SOCKS5_PASS",
"LOCAL_DNS_ENABLED", "LOCAL_DNS_IP", "LOCAL_DNS_PORT",
"LOCAL_DNS_CACHE_MAX_RECORDS", "LOCAL_DNS_CACHE_TTL_SECONDS",
"LOCAL_DNS_PENDING_TIMEOUT_SECONDS", "DNS_RESPONSE_FRAGMENT_TIMEOUT_SECONDS",
"LOCAL_DNS_CACHE_PERSIST_TO_FILE", "LOCAL_DNS_CACHE_FLUSH_INTERVAL_SECONDS",
"STREAM_RESOLVER_FAILOVER_RESEND_THRESHOLD", "STREAM_RESOLVER_FAILOVER_COOLDOWN",
"RECHECK_INACTIVE_SERVERS_ENABLED", "AUTO_DISABLE_TIMEOUT_SERVERS",
"AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS", "BASE_ENCODE_DATA", "COMPRESSION_MIN_SIZE",
"MIN_UPLOAD_MTU", "MIN_DOWNLOAD_MTU", "MAX_UPLOAD_MTU", "MAX_DOWNLOAD_MTU",
"MTU_TEST_RETRIES", "MTU_TEST_TIMEOUT", "MTU_TEST_PARALLELISM",
"SAVE_MTU_SERVERS_TO_FILE", "MTU_SERVERS_FILE_NAME", "MTU_SERVERS_FILE_FORMAT",
"MTU_USING_SECTION_SEPARATOR_TEXT", "MTU_REMOVED_SERVER_LOG_FORMAT",
"MTU_ADDED_SERVER_LOG_FORMAT", "MTU_REACTIVE_ADDED_SERVER_LOG_FORMAT",
"MTU_EXPORT_URI",
"RX_TX_WORKERS", "TUNNEL_PROCESS_WORKERS", "TUNNEL_PACKET_TIMEOUT_SECONDS",
"DISPATCHER_IDLE_POLL_INTERVAL_SECONDS", "RX_CHANNEL_SIZE",
"SOCKS_UDP_ASSOCIATE_READ_TIMEOUT_SECONDS",
"CLIENT_TERMINAL_STREAM_RETENTION_SECONDS",
"CLIENT_CANCELLED_SETUP_RETENTION_SECONDS",
"SESSION_INIT_RETRY_BASE_SECONDS", "SESSION_INIT_RETRY_STEP_SECONDS",
"SESSION_INIT_RETRY_LINEAR_AFTER", "SESSION_INIT_RETRY_MAX_SECONDS",
"SESSION_INIT_BUSY_RETRY_INTERVAL_SECONDS", "SESSION_INIT_RACING_COUNT",
"PING_AGGRESSIVE_INTERVAL_SECONDS", "PING_LAZY_INTERVAL_SECONDS",
"PING_COOLDOWN_INTERVAL_SECONDS", "PING_COLD_INTERVAL_SECONDS",
"PING_WARM_THRESHOLD_SECONDS", "PING_COOL_THRESHOLD_SECONDS",
"PING_COLD_THRESHOLD_SECONDS",
"MAX_PACKETS_PER_BATCH", "ARQ_WINDOW_SIZE", "ARQ_INITIAL_RTO_SECONDS",
"ARQ_MAX_RTO_SECONDS", "ARQ_CONTROL_INITIAL_RTO_SECONDS",
"ARQ_CONTROL_MAX_RTO_SECONDS", "ARQ_MAX_CONTROL_RETRIES",
"ARQ_MAX_DATA_RETRIES", "ARQ_DATA_PACKET_TTL_SECONDS",
"ARQ_CONTROL_PACKET_TTL_SECONDS", "ARQ_DATA_NACK_MAX_GAP",
"ARQ_DATA_NACK_INITIAL_DELAY_SECONDS", "ARQ_DATA_NACK_REPEAT_SECONDS",
"ARQ_INACTIVITY_TIMEOUT_SECONDS", "ARQ_TERMINAL_DRAIN_TIMEOUT_SECONDS",
"ARQ_TERMINAL_ACK_WAIT_TIMEOUT_SECONDS"
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -68,6 +69,7 @@ class MasterDnsVpnService : VpnService() {
private var sharingSocksServer: java.net.ServerSocket? = null
private var sharingHttpServer: java.net.ServerSocket? = null
private var logTailJob: Job? = null
private var notificationStatsJob: Job? = null
private var wakeLock: PowerManager.WakeLock? = null
private var mtuExportTargetUri: String? = null
private var mtuConfigDir: File? = null
Expand Down Expand Up @@ -113,6 +115,7 @@ class MasterDnsVpnService : VpnService() {

// Show foreground notification
startForeground(NOTIFICATION_ID, buildNotification(getString(R.string.notification_connecting)))
startNotificationStats()
acquireWakeLock()

// Load profile from DB
Expand Down Expand Up @@ -189,7 +192,16 @@ class MasterDnsVpnService : VpnService() {
localDnsPortOverride = if (proxyMode) null else safeDnsPort
)
)
if (runtimeProfile.resolvers.isNotBlank()) {
val importedResolverFile = ResolverAnalyzer.profileImportedResolver(runtimeProfile)
?.takeIf { File(it.cachedPath).isFile }
if (importedResolverFile != null) {
File(importedResolverFile.cachedPath).copyTo(resolversFile, overwrite = true)
VpnManager.appendLog("Using imported resolver file: ${importedResolverFile.displayName}")
VpnManager.appendLog("Resolver stats: ${importedResolverFile.stats.summary()}")
} else if (ResolverAnalyzer.profileImportedResolver(runtimeProfile) != null) {
VpnManager.appendLog("Imported resolver file is missing; falling back to inline resolvers")
resolversFile.writeText(ConfigGenerator.generateResolvers(runtimeProfile))
} else if (runtimeProfile.resolvers.isNotBlank()) {
resolversFile.writeText(ConfigGenerator.generateResolvers(runtimeProfile))
} else if (!resolversFile.exists() || resolversFile.readText().isBlank()) {
resolversFile.writeText(ConfigGenerator.generateResolvers(runtimeProfile))
Expand Down Expand Up @@ -250,9 +262,7 @@ class MasterDnsVpnService : VpnService() {
VpnManager.appendLog("Proxy mode active: skipping Android VpnService TUN setup")
VpnManager.updateState(VpnManager.VpnState.CONNECTED)
VpnManager.startTrafficMonitor(this@MasterDnsVpnService)
val notification = buildNotification("Proxy mode active on port $socksPort")
val manager = getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager
manager.notify(NOTIFICATION_ID, notification)
notifyStatus("Proxy mode active on port $socksPort")
return@launch
}

Expand Down Expand Up @@ -360,9 +370,7 @@ class MasterDnsVpnService : VpnService() {
VpnManager.appendLog("VPN connected successfully!")

// Update notification
val notification = buildNotification(getString(R.string.notification_connected))
val manager = getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager
manager.notify(NOTIFICATION_ID, notification)
notifyStatus(getString(R.string.notification_connected))

} catch (e: CancellationException) {
VpnManager.appendLog("Connection canceled")
Expand Down Expand Up @@ -409,6 +417,7 @@ class MasterDnsVpnService : VpnService() {
httpProxyJob?.cancel()
sharingSocksJob?.cancel()
logTailJob?.cancel()
notificationStatsJob?.cancel()
runCatching { sharingSocksServer?.close() }
sharingSocksServer = null
runCatching { sharingHttpServer?.close() }
Expand Down Expand Up @@ -452,12 +461,73 @@ class MasterDnsVpnService : VpnService() {
return NotificationCompat.Builder(this, App.CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setSmallIcon(R.drawable.ic_vpn_key)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}

private fun startNotificationStats() {
notificationStatsJob?.cancel()
notificationStatsJob = serviceScope.launch {
while (isActive) {
notifyStatus(notificationStatusText())
delay(2000L)
}
}
}

private fun notifyStatus(text: String) {
val manager = getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager
manager.notify(NOTIFICATION_ID, buildNotification(text))
}

private fun notificationStatusText(): String {
val state = VpnManager.state.value
val scan = VpnManager.scanStatus.value
val down = formatBytesPerSecond(VpnManager.downloadSpeedBps.value)
val up = formatBytesPerSecond(VpnManager.uploadSpeedBps.value)
val totals = getString(
R.string.notification_traffic_totals,
formatBytes(VpnManager.downloadTotalBytes.value),
formatBytes(VpnManager.uploadTotalBytes.value)
)
return when {
state == VpnManager.VpnState.CONNECTING && scan.scanning -> {
val scanned = scan.validCount + scan.rejectedCount
val total = scan.scanTotalFromCore.takeIf { it > 0 }
if (total != null) {
getString(R.string.notification_scanning_dns, scanned, total, down, up)
} else {
getString(R.string.notification_connecting_with_speed, down, up)
}
}
state == VpnManager.VpnState.CONNECTED -> {
scan.activeResolvers.takeIf { it > 0 }?.let {
getString(R.string.notification_connected_with_resolvers, down, up, totals, it)
} ?: getString(R.string.notification_connected_with_totals, down, up, totals)
}
state == VpnManager.VpnState.DISCONNECTING -> getString(R.string.notification_disconnecting_with_totals, totals)
state == VpnManager.VpnState.ERROR -> VpnManager.errorMessage.value ?: "Connection error"
else -> getString(R.string.notification_connecting)
}
}

private fun formatBytesPerSecond(bytes: Long): String = "${formatBytes(bytes)}/s"

private fun formatBytes(bytes: Long): String {
val kb = 1024.0
val mb = kb * 1024.0
val gb = mb * 1024.0
return when {
bytes >= gb -> String.format("%.2f GB", bytes / gb)
bytes >= mb -> String.format("%.2f MB", bytes / mb)
bytes >= kb -> String.format("%.1f KB", bytes / kb)
else -> "$bytes B"
}
}

override fun onDestroy() {
if (!isStopping) {
try {
Expand All @@ -473,6 +543,7 @@ class MasterDnsVpnService : VpnService() {
vpnInterface = null
}
releaseWakeLock()
notificationStatsJob?.cancel()
serviceScope.cancel()
super.onDestroy()
}
Expand Down
Loading
Loading