Skip to content
Merged
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
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,16 @@ branch names, credentials, signing material, or other secrets.
downloads. Keep local profiles/selections and current tunnels; effective live changes mark
pending reconnect rather than starting/stopping VPN automatically.

- Strict source validation is device-local, opt-in and Android 10+; apply on reconnection.
Disabled sessions retain the original native data path. Enabled sessions fail closed on
unknown owners on lookup, validate TCP admission, and block TUN Echo while preserving ordinary
ICMP error handling. Standard caches UDP admission per live flow for a fixed non-sliding second;
Advanced checks every outgoing datagram. New opt-in defaults to Standard; legacy enabled settings
migrate to Advanced. Keep both levels local and preserve them across imports. Visibility reports
the actual running level and pending changes, not proof of a separate-app bypass test.
Bound all ownership JNI queries across sessions (including UDP rechecks), avoid ordinary payload copies and repeated prefix coalescing
in the ICMP filter, and prepare numeric UDP endpoint strings once per flow.

## Architecture landmarks

- Validate networking optimizations against latency measurements. Prefer bounded racing of alternatives
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ All guides: [English](docs/en/index.md) / [Русский](docs/ru/index.md).
- HTTPS CONNECT over HTTP/1.1 or multiplexed HTTP/2; optional HTTP/3 preference with HTTPS fallback.
- SOCKS5 TCP/UDP, experimental MASQUE TCP/UDP over HTTP/3 and SSH.
- Per-app routing, local-network bypass, optional IPv6 and Android Always-on VPN.
- Optional [strict source validation](docs/en/source-validation.md) on Android 10+: app ownership checks and ping blocking, applied on reconnection.
- Multiple profiles, import/export, HTTPS configuration subscriptions and automatic failover.
- DNS-over-HTTPS, optional plain TCP/UDP DNS, selectable TLS/QUIC/SSH fingerprints and custom JA3.
- Traffic totals/rates, passive TCP/QUIC metrics, connection tests, download/upload speed tests
Expand Down
13 changes: 11 additions & 2 deletions app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import net.megaproxy487.model.FailoverMode
import net.megaproxy487.model.TlsProfile
import net.megaproxy487.model.ProxyType
import net.megaproxy487.model.SourceValidationLevel
import net.megaproxy487.vpn.ConnectionStatsReader
import net.megaproxy487.vpn.PersistentDiagnosticLog
import net.megaproxy487.vpn.VpnTransportProtocol
Expand All @@ -27,7 +28,12 @@ class VpnDeviceTest : DeviceTestBase() {
@Test fun socks5AuthenticatedTcpUdpStopAndRestart() = socks5Traffic(true)
@Test fun socks5AnonymousTcpUdpStopAndRestart() = socks5Traffic(false)

private fun socks5Traffic(authenticated: Boolean) {
@Test fun strictSourceValidatedTcpUdpStopAndRestart() {
org.junit.Assume.assumeTrue(Build.VERSION.SDK_INT >= 29)
SourceValidationLevel.entries.forEach { socks5Traffic(true, strict = true, level = it) }
}

private fun socks5Traffic(authenticated: Boolean, strict: Boolean = false, level: SourceValidationLevel = SourceValidationLevel.STANDARD) {
io {
val profile = store.activeProfile()
store.saveProfile(profile.copy(config = profile.config.copy(
Expand All @@ -37,12 +43,14 @@ class VpnDeviceTest : DeviceTestBase() {
password = if (authenticated) argument("proxyPassword") else "",
)))
store.saveGlobalConnectionSettings(store.globalConnectionSettings().copy(
routeAllApps = false, selectedPackages = setOf(context.packageName), bypassLocalNetworks = false))
routeAllApps = false, selectedPackages = setOf(context.packageName), bypassLocalNetworks = false, strictSourceValidation = strict, sourceValidationLevel = level))
}
saved()
directOriginUnavailable()
repeat(2) {
connect()
assertEquals(strict, ProxyVpnService.strictSourceValidationActive)
assertEquals(if (strict) level else null, ProxyVpnService.sourceValidationLevelActive)
roundTrip()
listOf(1200, 1350, 4096).forEach(::udpRoundTrip)
await("SOCKS5 protocol indicator did not update") { VpnRuntimeState.transportProtocol.value == VpnTransportProtocol.SOCKS5 }
Expand All @@ -51,6 +59,7 @@ class VpnDeviceTest : DeviceTestBase() {
}
click(R.string.disconnect)
stopped()
assertFalse(ProxyVpnService.strictSourceValidationActive)
directOriginUnavailable()
}
}
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/net/megaproxy487/SourceProtection.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package net.megaproxy487

import net.megaproxy487.model.SourceValidationLevel

internal enum class SourceProtectionStatus { UNSUPPORTED, STOPPED, ACTIVE, DISABLED, RECONNECT }

internal fun sourceProtectionStatus(sdk: Int, configured: SourceValidationLevel?, running: Boolean, active: SourceValidationLevel?): SourceProtectionStatus = when {
sdk < 29 -> SourceProtectionStatus.UNSUPPORTED
!running -> SourceProtectionStatus.STOPPED
configured != active -> SourceProtectionStatus.RECONNECT
active != null -> SourceProtectionStatus.ACTIVE
else -> SourceProtectionStatus.DISABLED
}
40 changes: 40 additions & 0 deletions app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,46 @@ internal fun SplitTunnelScreen(activity: Activity, onBack: () -> Unit) {
}
}
}
item {
val supported = android.os.Build.VERSION.SDK_INT >= 29
Row(
Modifier.fillMaxWidth().heightIn(min = 56.dp).toggleable(
value = settings.strictSourceValidation,
enabled = supported || settings.strictSourceValidation,
onValueChange = { updateSettings(settings.copy(strictSourceValidation = it)) },
role = Role.Switch,
),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.strict_source_title))
Text(stringResource(if (supported) R.string.strict_source_description else R.string.strict_source_unsupported), style = MaterialTheme.typography.bodySmall)
}
Switch(settings.strictSourceValidation, null, enabled = supported || settings.strictSourceValidation)
}
}
if (settings.strictSourceValidation && android.os.Build.VERSION.SDK_INT >= 29) {
item {
Column(Modifier.selectableGroup()) {
net.megaproxy487.model.SourceValidationLevel.entries.forEach { level ->
Row(
Modifier.fillMaxWidth().heightIn(min = 56.dp).selectable(
selected = settings.sourceValidationLevel == level,
onClick = { updateSettings(settings.copy(sourceValidationLevel = level)) },
role = Role.RadioButton,
),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
RadioButton(settings.sourceValidationLevel == level, null)
Column(Modifier.weight(1f)) {
Text(stringResource(level.labelResource))
Text(stringResource(if (level == net.megaproxy487.model.SourceValidationLevel.STANDARD) R.string.source_level_standard_detail else R.string.source_level_advanced_detail), style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
}
if (!settings.routeAllApps) {
item {
val checked = activity.packageName in settings.selectedPackages
Expand Down
22 changes: 21 additions & 1 deletion app/src/main/java/net/megaproxy487/VisibilityScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ import java.net.URL
import net.megaproxy487.data.boundedJsonObject

private enum class VisibilityState(val labelResource: Int) {
ACTIVE(R.string.strict_source_active),
DISABLED(R.string.strict_source_disabled),
DETECTED(R.string.detected),
SELECTED_APPS_ONLY(R.string.selected_apps_only),
NOT_DETECTED(R.string.not_detected),
Expand All @@ -76,13 +78,14 @@ private data class VisibilityReport(
val global: List<VisibilityCheck>,
val selectedApps: List<VisibilityCheck>,
val splitTunneling: Boolean,
val sourceProtection: VisibilityCheck,
)

@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun VisibilityScreen(activity: Activity, onBack: () -> Unit, runNetworkChecks: Boolean = true) {
var revision by remember { mutableStateOf(0) }
val report = readConfiguration(revision) { buildVisibilityReport(activity) } ?: return
val report = readConfiguration(revision to net.megaproxy487.vpn.VpnRuntimeState.connection.value) { buildVisibilityReport(activity) } ?: return
var proxyCheckChecks by remember { mutableStateOf<List<VisibilityCheck>>(emptyList()) }
LaunchedEffect(revision) {
if (!runNetworkChecks) return@LaunchedEffect
Expand Down Expand Up @@ -124,6 +127,7 @@ internal fun VisibilityScreen(activity: Activity, onBack: () -> Unit, runNetwork
stringResource(if (report.splitTunneling) R.string.visible_to_selected_apps else R.string.vpn_network_signals),
report.selectedApps,
)
VisibilitySection(stringResource(R.string.strict_source_section), listOf(report.sourceProtection))
VisibilitySection(stringResource(R.string.proxycheck_reputation), proxyCheckChecks)
Text(
stringResource(R.string.visibility_disclaimer),
Expand Down Expand Up @@ -154,6 +158,8 @@ private fun VisibilitySection(title: String, checks: List<VisibilityCheck>) {
@Composable
private fun VisibilityBadge(state: VisibilityState) {
val background = when (state) {
VisibilityState.ACTIVE -> Color(0xFF2E7D32)
VisibilityState.DISABLED -> MaterialTheme.colorScheme.outline
VisibilityState.DETECTED -> Color(0xFFB3261E)
VisibilityState.SELECTED_APPS_ONLY -> Color(0xFFF9A825)
VisibilityState.NOT_DETECTED -> Color(0xFF2E7D32)
Expand Down Expand Up @@ -312,10 +318,24 @@ private fun buildVisibilityReport(context: Context): VisibilityReport {
vpnCheck(context.uiText(R.string.virtual_default_route), virtualDefaultRoute, context.uiText(R.string.virtual_route_yes), context.uiText(R.string.virtual_route_no)),
vpnCheck(context.uiText(R.string.virtual_dns_address), virtualDns, context.uiText(R.string.virtual_dns_yes), context.uiText(R.string.virtual_dns_no)),
),
sourceProtection = sourceProtectionCheck(context, settings.sourceValidationLevel.takeIf { settings.strictSourceValidation }),
splitTunneling = splitTunneling,
)
}

private const val PROXYCHECK_LOOKUP_URL = "https://proxycheck.io/v3/"
private const val PROXYCHECK_CACHE = "proxycheck_visibility_cache"
private const val PROXYCHECK_CACHE_TTL_MS = 24 * 60 * 60 * 1000L

private fun sourceProtectionCheck(context: Context, configured: net.megaproxy487.model.SourceValidationLevel?): VisibilityCheck {
val active = net.megaproxy487.vpn.ProxyVpnService.sourceValidationLevelActive
val status = sourceProtectionStatus(Build.VERSION.SDK_INT, configured, net.megaproxy487.vpn.ProxyVpnService.isRunning, active)
val (state, detail) = when (status) {
SourceProtectionStatus.UNSUPPORTED -> VisibilityState.UNAVAILABLE to R.string.strict_source_unsupported
SourceProtectionStatus.STOPPED -> VisibilityState.UNAVAILABLE to R.string.strict_source_stopped
SourceProtectionStatus.ACTIVE -> VisibilityState.ACTIVE to if (active == net.megaproxy487.model.SourceValidationLevel.STANDARD) R.string.source_standard_active_detail else R.string.strict_source_active_detail
SourceProtectionStatus.DISABLED -> VisibilityState.DISABLED to R.string.strict_source_disabled_detail
SourceProtectionStatus.RECONNECT -> VisibilityState.UNKNOWN to if (active == null) R.string.strict_source_pending_enable else if (configured == null) R.string.strict_source_pending_disable else R.string.source_level_pending
}
return VisibilityCheck(context.uiText(R.string.strict_source_title), state, context.uiText(detail))
}
2 changes: 2 additions & 0 deletions app/src/main/java/net/megaproxy487/data/ConfigEdits.kt
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ internal fun ConfigEdits<ProxyProfile>.profile(before: ProxyProfile, after: Prox

internal fun ConfigEdits<GlobalConnectionSettings>.settings(before: GlobalConnectionSettings, after: GlobalConnectionSettings): ConfigEdit<GlobalConnectionSettings> {
record("mtu", before.mtu, after.mtu) { it.copy(mtu = after.mtu) }
record("sourceValidationLevel", before.sourceValidationLevel, after.sourceValidationLevel) { it.copy(sourceValidationLevel = after.sourceValidationLevel) }
record("strictSourceValidation", before.strictSourceValidation, after.strictSourceValidation) { it.copy(strictSourceValidation = after.strictSourceValidation) }
record("tlsProfile", before.tlsProfile, after.tlsProfile) { it.copy(tlsProfile = after.tlsProfile) }
record("sshProfile", before.sshProfile, after.sshProfile) { it.copy(sshProfile = after.sshProfile) }
record("sshAuthMode", before.sshAuthMode, after.sshAuthMode) { it.copy(sshAuthMode = after.sshAuthMode) }
Expand Down
10 changes: 9 additions & 1 deletion app/src/main/java/net/megaproxy487/data/ConfigStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -770,11 +770,13 @@ class ConfigStore(context: Context) {
put("routeAllApps", settings.routeAllApps)
put("bypassLocalNetworks", settings.bypassLocalNetworks)
put("mtu", settings.mtu)
put("strictSourceValidation", settings.strictSourceValidation)
put("sourceValidationLevel", settings.sourceValidationLevel.name)
}.toString()

private fun decodeGlobalConnectionSettings(value: String): GlobalConnectionSettings = operationResult {
val item = JSONObject(value)
for (key in listOf("routeAllApps", "bypassLocalNetworks", "allowIpv6", "megaProxyRoutingDefaultApplied")) {
for (key in listOf("routeAllApps", "bypassLocalNetworks", "allowIpv6", "megaProxyRoutingDefaultApplied", "strictSourceValidation")) {
require(!item.has(key) || item.get(key) is Boolean)
}
for (key in listOf("packages", "failoverProfileIds")) {
Expand All @@ -791,6 +793,12 @@ class ConfigStore(context: Context) {
val parsedTls = enumValue(item.optString("fingerprint"), TlsProfile.DEFAULT)
GlobalConnectionSettings(
mtu = mtu,
strictSourceValidation = item.optBoolean("strictSourceValidation", false),
sourceValidationLevel = if (item.has("sourceValidationLevel")) {
net.megaproxy487.model.SourceValidationLevel.valueOf(item.getString("sourceValidationLevel"))
} else if (item.optBoolean("strictSourceValidation", false)) {
net.megaproxy487.model.SourceValidationLevel.ADVANCED
} else net.megaproxy487.model.SourceValidationLevel.STANDARD,
tlsProfile = parsedTls.takeIf { it.available } ?: TlsProfile.DEFAULT,
sshProfile = enumValue(item.optString("sshProfile"), SshProfile.DEFAULT),
sshAuthMode = enumValue(item.optString("sshAuthMode"), SshAuthMode.AUTO),
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ data class PortableConfiguration(
) {
internal fun mergeGlobalSettings(current: GlobalConnectionSettings): GlobalConnectionSettings {
val incoming = globalConnectionSettings ?: return current
val fields = globalSettingFields ?: return incoming.copy(mtu = current.mtu)
val fields = globalSettingFields ?: return incoming.copy(mtu = current.mtu, strictSourceValidation = current.strictSourceValidation, sourceValidationLevel = current.sourceValidationLevel)
return current.copy(
tlsProfile = if ("tls.fingerprint" in fields) incoming.tlsProfile else current.tlsProfile,
customJa3 = if ("tls.customJa3" in fields) incoming.customJa3 else current.customJa3,
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/net/megaproxy487/model/ProxyConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import net.megaproxy487.R
const val DEFAULT_VPN_MTU = 1400
val VPN_MTU_RANGE = 1280..65535

enum class SourceValidationLevel(val udpRecheckMillis: Long, @StringRes val labelResource: Int) {
STANDARD(1000, R.string.source_level_standard),
ADVANCED(0, R.string.source_level_advanced),
}

data class ProxyConfig(
val type: ProxyType = ProxyType.HTTPS,
val host: String = "",
Expand All @@ -27,6 +32,8 @@ data class ProxyConfig(
val routeAllApps: Boolean = true,
val bypassLocalNetworks: Boolean = true,
val mtu: Int = DEFAULT_VPN_MTU,
val strictSourceValidation: Boolean = false,
val sourceValidationLevel: SourceValidationLevel = SourceValidationLevel.STANDARD,
val resolvedProxyIp: String = "",
val privateKey: String = "",
val sshProfile: SshProfile = SshProfile.DEFAULT,
Expand Down Expand Up @@ -227,6 +234,8 @@ data class GlobalConnectionSettings(
val routeAllApps: Boolean = true,
val bypassLocalNetworks: Boolean = true,
val mtu: Int = DEFAULT_VPN_MTU,
val strictSourceValidation: Boolean = false,
val sourceValidationLevel: SourceValidationLevel = SourceValidationLevel.STANDARD,
) {
fun applyTo(config: ProxyConfig): ProxyConfig = config.copy(
profile = if (tlsProfile == TlsProfile.DEFAULT) TlsProfile.CHROME_ANDROID else tlsProfile,
Expand All @@ -241,6 +250,8 @@ data class GlobalConnectionSettings(
routeAllApps = routeAllApps,
bypassLocalNetworks = bypassLocalNetworks,
mtu = mtu,
strictSourceValidation = strictSourceValidation,
sourceValidationLevel = sourceValidationLevel,
)
}

Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/net/megaproxy487/vpn/BridgeCallbacks.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,20 @@ internal class BridgeReporter(
}
}
}


internal class BridgeSourceValidator(
private val enabled: () -> Boolean,
private val allowedUids: Set<Int>?,
private val resolve: (Int, String, Int, String, Int) -> Int,
) : mobile.SourceValidator {
override fun allow(protocol: Long, source: String, sourcePort: Long, destination: String, destinationPort: Long): Boolean {
if (!enabled() || (protocol != 6L && protocol != 17L) || sourcePort !in 1..65535L || destinationPort !in 1..65535L) return false
return try {
val uid = resolve(protocol.toInt(), source, sourcePort.toInt(), destination, destinationPort.toInt())
enabled() && uid >= 0 && (allowedUids == null || uid in allowedUids)
} catch (_: Exception) {
false
}
}
}
Loading
Loading