diff --git a/app/src/test/java/com/duckduckgo/networkprotection/impl/config/WgVpnRoutesTest.kt b/app/src/test/java/com/duckduckgo/networkprotection/impl/config/WgVpnRoutesTest.kt new file mode 100644 index 000000000000..09f22e39918b --- /dev/null +++ b/app/src/test/java/com/duckduckgo/networkprotection/impl/config/WgVpnRoutesTest.kt @@ -0,0 +1,187 @@ +package com.duckduckgo.networkprotection.impl.config + +import org.junit.Assert.assertEquals +import org.junit.Test + +class WgVpnRoutesTest { + + private val routes = WgVpnRoutes() + + @Test + fun `test exclude 10 0 0 0 8 and 127 0 0 0 8`() { + val excludedRanges = mapOf("10.0.0.0" to 8, "127.0.0.0" to 8) + val result = routes.generateVpnRoutes(excludedRanges) + + val expectedRoutes = mapOf( + "0.0.0.0" to 5, + "8.0.0.0" to 7, + "11.0.0.0" to 8, + "12.0.0.0" to 6, + "16.0.0.0" to 4, + "32.0.0.0" to 3, + "64.0.0.0" to 3, + "96.0.0.0" to 4, + "112.0.0.0" to 5, + "120.0.0.0" to 6, + "124.0.0.0" to 7, + "126.0.0.0" to 8, + "128.0.0.0" to 1, + ) + + assertEquals(expectedRoutes, result) + } + + @Test + fun `test exclude only 10 0 0 0 8`() { + val excludedRanges = mapOf("10.0.0.0" to 8) + val result = routes.generateVpnRoutes(excludedRanges) + + val expectedRoutes = mapOf( + "0.0.0.0" to 5, + "8.0.0.0" to 7, + "11.0.0.0" to 8, + "12.0.0.0" to 6, + "16.0.0.0" to 4, + "32.0.0.0" to 3, + "64.0.0.0" to 2, + "128.0.0.0" to 1, + ) + + assertEquals(expectedRoutes, result) + } + + @Test + fun `test exclude full IPv4 range`() { + val excludedRanges = mapOf("0.0.0.0" to 0) + val result = routes.generateVpnRoutes(excludedRanges) + + println(result) + assertEquals(emptyMap(), result) + } + + @Test + fun `test exclude nothing (full range should remain)`() { + val excludedRanges = emptyMap() + val result = routes.generateVpnRoutes(excludedRanges) + + val expectedRoutes = mapOf("0.0.0.0" to 0) + assertEquals(expectedRoutes, result) + } + + @Test + fun `test exclude small subnets`() { + val excludedRanges = mapOf("192.168.1.0" to 24, "192.168.2.0" to 24) + val result = routes.generateVpnRoutes(excludedRanges) + + val expectedRoutes = mapOf( + "0.0.0.0" to 1, + "128.0.0.0" to 2, + "192.0.0.0" to 9, + "192.128.0.0" to 11, + "192.160.0.0" to 13, + "192.168.0.0" to 24, + "192.168.3.0" to 24, + "192.168.4.0" to 22, + "192.168.8.0" to 21, + "192.168.16.0" to 20, + "192.168.32.0" to 19, + "192.168.64.0" to 18, + "192.168.128.0" to 17, + "192.169.0.0" to 16, + "192.170.0.0" to 15, + "192.172.0.0" to 14, + "192.176.0.0" to 12, + "192.192.0.0" to 10, + "193.0.0.0" to 8, + "194.0.0.0" to 7, + "196.0.0.0" to 6, + "200.0.0.0" to 5, + "208.0.0.0" to 4, + "224.0.0.0" to 3, + ) + + assertEquals(expectedRoutes, result) + } + + @Test + fun `test exclude single IP address`() { + val excludedRanges = mapOf( + "20.253.26.112" to 32, + ) + val result = routes.generateVpnRoutes(excludedRanges) + + val expectedRoutes = mapOf( + "0.0.0.0" to 4, + "16.0.0.0" to 6, + "20.0.0.0" to 9, + "20.128.0.0" to 10, + "20.192.0.0" to 11, + "20.224.0.0" to 12, + "20.240.0.0" to 13, + "20.248.0.0" to 14, + "20.252.0.0" to 16, + "20.253.0.0" to 20, + "20.253.16.0" to 21, + "20.253.24.0" to 23, + "20.253.26.0" to 26, + "20.253.26.64" to 27, + "20.253.26.96" to 28, + "20.253.26.113" to 32, + "20.253.26.114" to 31, + "20.253.26.116" to 30, + "20.253.26.120" to 29, + "20.253.26.128" to 25, + "20.253.27.0" to 24, + "20.253.28.0" to 22, + "20.253.32.0" to 19, + "20.253.64.0" to 18, + "20.253.128.0" to 17, + "20.254.0.0" to 15, + "21.0.0.0" to 8, + "22.0.0.0" to 7, + "24.0.0.0" to 5, + "32.0.0.0" to 3, + "64.0.0.0" to 2, + "128.0.0.0" to 1, + ) + + assertEquals(expectedRoutes, result) + } + + @Test + fun `test exclude multiple subnets including single IP addresses`() { + val excludedRanges = mapOf( + "10.0.0.0" to 8, + "127.0.0.0" to 8, + "169.254.0.0" to 16, + "172.16.0.0" to 12, + "192.168.0.0" to 16, + "224.0.0.0" to 4, + "240.0.0.0" to 4, + "20.93.77.32" to 32, + "20.253.26.112" to 32, + ) + val result = routes.generateVpnRoutes(excludedRanges) + + val expectedRoutes = WgVpnRoutes.wgVpnDefaultRoutes + + assertEquals(expectedRoutes, result) + } + + @Test + fun `test exclude multiple subnets including local and including single IP addresses`() { + val excludedRanges = mapOf( + "127.0.0.0" to 8, + "169.254.0.0" to 16, + "224.0.0.0" to 4, + "240.0.0.0" to 4, + "20.93.77.32" to 32, + "20.253.26.112" to 32, + ) + val result = routes.generateVpnRoutes(excludedRanges) + + val expectedRoutes = WgVpnRoutes.wgVpnRoutesIncludingLocal + + assertEquals(expectedRoutes, result) + } +} diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/VpnRemoteFeatures.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/VpnRemoteFeatures.kt index 2b2e5e20f9d9..de92f81ae35c 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/VpnRemoteFeatures.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/VpnRemoteFeatures.kt @@ -51,6 +51,9 @@ interface VpnRemoteFeatures { @DefaultValue(false) fun allowDnsBlockMalware(): Toggle + + @DefaultValue(false) + fun localVpnControllerDns(): Toggle } @ContributesBinding(AppScope::class) diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/config/CIDR.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/config/CIDR.kt new file mode 100644 index 000000000000..4cda7275b2bc --- /dev/null +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/config/CIDR.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.networkprotection.impl.config + +import java.net.InetAddress +import java.net.UnknownHostException +import kotlin.math.floor +import kotlin.math.ln +import logcat.logcat + +data class CIDR( + val address: InetAddress, + val prefix: Int, +) : Comparable { + + val start: InetAddress? + get() = (this.address.toLong() and prefix2mask(this.prefix)).toInetAddress() + + val end: InetAddress? + get() = ((this.address.toLong() and prefix2mask(this.prefix)) + (1L shl (32 - this.prefix)) - 1).toInetAddress() + + override fun toString(): String { + return address.hostAddress + "/" + prefix + "=" + start!!.hostAddress + "..." + end!!.hostAddress + } + + @Suppress("NAME_SHADOWING") + override operator fun compareTo(other: CIDR): Int { + val cidrAsLong = this.address.toLong() + val otherCidrAsLong = other.address.toLong() + return cidrAsLong.compareTo(otherCidrAsLong) + } + + companion object { + @Throws(UnknownHostException::class) + fun createFrom( + start: InetAddress, + end: InetAddress, + ): List { + val listResult: MutableList = ArrayList() + + logcat { "toCIDR(" + start.hostAddress + "," + end.hostAddress + ")" } + + var from = start.toLong() + val to = end.toLong() + while (to >= from) { + var prefix: Byte = 32 + while (prefix > 0) { + val mask = prefix2mask(prefix - 1) + if ((from and mask) != from) break + prefix-- + } + + val max = (32 - floor(ln((to - from + 1).toDouble()) / ln(2.0))).toInt().toByte() + if (prefix < max) prefix = max + + listResult.add(CIDR(from.toInetAddress()!!, prefix.toInt())) + + from += (1u shl (32 - prefix)).toLong() + } + + for (cidr in listResult) { + logcat { cidr.toString() } + } + + return listResult + } + } +} + +private fun prefix2mask(bits: Int): Long { + return (-0x100000000L shr bits) and 0xFFFFFFFFL +} + +private fun Long.toInetAddress(): InetAddress? { + var addr = this + try { + val b = ByteArray(4) + for (i in b.indices.reversed()) { + b[i] = (addr and 0xFFL).toByte() + addr = addr shr 8 + } + return InetAddress.getByAddress(b) + } catch (ignore: UnknownHostException) { + return null + } +} + +internal fun InetAddress.minus1(): InetAddress? { + return (this.toLong() - 1).toInetAddress() +} + +internal fun InetAddress.plus1(): InetAddress? { + return (this.toLong() + 1).toInetAddress() +} + +internal fun InetAddress.toLong(): Long { + val addr = this + var result: Long = 0 + for (b in addr.address) result = result shl 8 or (b.toInt() and 0xFF).toLong() + return result +} diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/config/NetPDefaultConfigProvider.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/config/NetPDefaultConfigProvider.kt index 7390d00f9d35..1796e4711e2f 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/config/NetPDefaultConfigProvider.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/config/NetPDefaultConfigProvider.kt @@ -18,6 +18,9 @@ package com.duckduckgo.networkprotection.impl.config import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.VpnScope +import com.duckduckgo.networkprotection.impl.VpnRemoteFeatures +import com.duckduckgo.networkprotection.impl.configuration.CONTROLLER_NETP_DUCKDUCKGO_COM +import com.duckduckgo.networkprotection.impl.configuration.VpnLocalDns import com.duckduckgo.networkprotection.impl.exclusion.NetPExclusionListRepository import com.duckduckgo.networkprotection.impl.exclusion.systemapps.SystemAppsExclusionRepository import com.duckduckgo.networkprotection.impl.settings.NetPSettingsLocalConfig @@ -27,6 +30,7 @@ import java.net.Inet4Address import java.net.InetAddress import javax.inject.Inject import kotlinx.coroutines.withContext +import logcat.logcat interface NetPDefaultConfigProvider { fun mtu(): Int = 1280 @@ -53,6 +57,8 @@ class RealNetPDefaultConfigProvider @Inject constructor( private val netPSettingsLocalConfig: NetPSettingsLocalConfig, private val systemAppsExclusionRepository: SystemAppsExclusionRepository, private val netpVpnSettingsDataStore: NetpVpnSettingsDataStore, + private val vpnRemoteFeatures: VpnRemoteFeatures, + private val vpnLocalDns: VpnLocalDns, ) : NetPDefaultConfigProvider { override suspend fun exclusionList(): Set { return mutableSetOf().apply { @@ -62,14 +68,45 @@ class RealNetPDefaultConfigProvider @Inject constructor( } override suspend fun routes(): Map = withContext(dispatcherProvider.io()) { + val isLocalDnsEnabled = vpnRemoteFeatures.localVpnControllerDns().isEnabled() + return@withContext if (netPSettingsLocalConfig.vpnExcludeLocalNetworkRoutes().isEnabled()) { - WgVpnRoutes.wgVpnDefaultRoutes.toMutableMap().apply { + val routes = if (isLocalDnsEnabled) { + // get the controller IPs + val controllerIPs = vpnLocalDns.lookup(CONTROLLER_NETP_DUCKDUCKGO_COM) + .filterIsInstance() + .mapNotNull { it.hostAddress } + .associateWith { 32 } + + val excludedRanges = WgVpnRoutes.vpnDefaultExcludedRoutes + controllerIPs + logcat { "Generating VPN routes dynamically, excluded ranges: $excludedRanges" } + + WgVpnRoutes().generateVpnRoutes(excludedRanges) + } else { + WgVpnRoutes.wgVpnDefaultRoutes + } + routes.toMutableMap().apply { fallbackDns().filterIsInstance().mapNotNull { it.hostAddress }.forEach { ip -> this[ip] = 32 } } } else { - WgVpnRoutes.wgVpnRoutesIncludingLocal.toMutableMap().apply { + val routes = if (isLocalDnsEnabled) { + // get the controller IPs + val controllerIPs = vpnLocalDns.lookup(CONTROLLER_NETP_DUCKDUCKGO_COM) + .filterIsInstance() + .mapNotNull { it.hostAddress } + .associateWith { 32 } + + val excludedRanges = WgVpnRoutes.vpnExcludedSpecialRoutes + controllerIPs + logcat { "Generating VPN routes dynamically, excluded ranges: $excludedRanges" } + + WgVpnRoutes().generateVpnRoutes(excludedRanges) + } else { + WgVpnRoutes.wgVpnRoutesIncludingLocal + } + + routes.toMutableMap().apply { fallbackDns().filterIsInstance().mapNotNull { it.hostAddress }.forEach { ip -> this[ip] = 32 } diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/config/WgVpnRoutes.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/config/WgVpnRoutes.kt index ee6e7161833d..f77ab033e3b6 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/config/WgVpnRoutes.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/config/WgVpnRoutes.kt @@ -16,7 +16,48 @@ package com.duckduckgo.networkprotection.impl.config -internal class WgVpnRoutes { +import java.net.InetAddress +import logcat.logcat + +typealias IpRange = Pair + +class WgVpnRoutes { + + fun generateVpnRoutes(excludedRanges: Map): Map { + // special cases + // empty, return all space + if (excludedRanges.isEmpty()) { + return mapOf("0.0.0.0" to 0) + } + // all, return no routes + if (excludedRanges["0.0.0.0"] == 0) { + return emptyMap() + } + + val listExclude = excludedRanges.map { CIDR(InetAddress.getByName(it.key), it.value) }.sorted() + + return runCatching { + val routes = mutableMapOf() + var start = InetAddress.getByName("0.0.0.0") + listExclude.forEach { exclude -> + CIDR.createFrom(start, exclude.start?.minus1()!!).forEach { include -> + routes[include.address] = include.prefix + } + start = exclude.end?.plus1()!! + } + + if (start.toLong() != 0L) { + CIDR.createFrom(start, InetAddress.getByName("255.255.255.255")).forEach { remaining -> + routes[remaining.address] = remaining.prefix + } + } + // routes + routes.mapKeys { it.key.hostAddress!! } + }.getOrDefault(emptyMap()).also { + logcat { "Runtime-generated routes $it" } + } + } + /** * We want to exclude local network traffic from routing through the VPN, but include everything else * @@ -25,21 +66,95 @@ internal class WgVpnRoutes { * * We exclude: * - private local IP ranges - * - CGNAT address range 100.64.0.0 -> 100.127.255.255 * - special IP addresses 127.0.0.0 to 127.255.255.255 * - not allocated to host (Class E) IP address - 240.0.0.0 to 255.255.255.255 * - link-local address range * - multicast (Class D) address range - 224.0.0.0 to 239.255.255.255 * - broadcast address + * - eun controller: 20.93.77.32 + * - use controller: 20.253.26.112 */ companion object { + private val CLASS_A_PRIVATE_IP_RANGE: IpRange = "10.0.0.0" to 8 + private val CLASS_B_PRIVATE_IP_RANGE: IpRange = "172.16.0.0" to 12 + private val CLASS_B_APIPA_IP_RANGE: IpRange = "169.254.0.0" to 16 + private val CLASS_C_PRIVATE_IP_RANGE: IpRange = "192.168.0.0" to 16 + private val CLASS_C_SPECIAL_IP_RANGE: IpRange = "127.0.0.0" to 8 + private val CLASS_D_SPECIAL_IP_RANGE: IpRange = "224.0.0.0" to 4 + + val vpnDefaultExcludedRoutes: Map = mapOf( + CLASS_A_PRIVATE_IP_RANGE, + CLASS_C_SPECIAL_IP_RANGE, + CLASS_B_APIPA_IP_RANGE, + CLASS_B_PRIVATE_IP_RANGE, + CLASS_C_PRIVATE_IP_RANGE, + CLASS_D_SPECIAL_IP_RANGE, + ) + + val vpnExcludedSpecialRoutes: Map = mapOf( + CLASS_C_SPECIAL_IP_RANGE, + CLASS_B_APIPA_IP_RANGE, + CLASS_D_SPECIAL_IP_RANGE, + ) + val wgVpnDefaultRoutes: Map = mapOf( "0.0.0.0" to 5, "8.0.0.0" to 7, // Excluded range: 10.0.0.0 -> 10.255.255.255 "11.0.0.0" to 8, "12.0.0.0" to 6, - "16.0.0.0" to 4, + "16.0.0.0" to 6, + "20.0.0.0" to 10, + "20.64.0.0" to 12, + "20.80.0.0" to 13, + "20.88.0.0" to 14, + "20.92.0.0" to 16, + "20.93.0.0" to 18, + "20.93.64.0" to 21, + "20.93.72.0" to 22, + "20.93.76.0" to 24, + "20.93.77.0" to 27, + // Excluded range: 20.93.77.32 -> 20.93.77.32 + "20.93.77.33" to 32, + "20.93.77.34" to 31, + "20.93.77.36" to 30, + "20.93.77.40" to 29, + "20.93.77.48" to 28, + "20.93.77.64" to 26, + "20.93.77.128" to 25, + "20.93.78.0" to 23, + "20.93.80.0" to 20, + "20.93.96.0" to 19, + "20.93.128.0" to 17, + "20.94.0.0" to 15, + "20.96.0.0" to 11, + "20.128.0.0" to 10, + "20.192.0.0" to 11, + "20.224.0.0" to 12, + "20.240.0.0" to 13, + "20.248.0.0" to 14, + "20.252.0.0" to 16, + "20.253.0.0" to 20, + "20.253.16.0" to 21, + "20.253.24.0" to 23, + "20.253.26.0" to 26, + "20.253.26.64" to 27, + "20.253.26.96" to 28, + // Excluded range: 20.253.26.112 -> 20.253.26.112 + "20.253.26.113" to 32, + "20.253.26.114" to 31, + "20.253.26.116" to 30, + "20.253.26.120" to 29, + "20.253.26.128" to 25, + "20.253.27.0" to 24, + "20.253.28.0" to 22, + "20.253.32.0" to 19, + "20.253.64.0" to 18, + "20.253.128.0" to 17, + "20.254.0.0" to 15, + "21.0.0.0" to 8, + "22.0.0.0" to 7, + "24.0.0.0" to 5, "32.0.0.0" to 3, "64.0.0.0" to 3, "96.0.0.0" to 4, @@ -88,12 +203,59 @@ internal class WgVpnRoutes { ) val wgVpnRoutesIncludingLocal: Map = mapOf( - "0.0.0.0" to 5, - "8.0.0.0" to 7, - "10.0.0.0" to 8, - "11.0.0.0" to 8, - "12.0.0.0" to 6, - "16.0.0.0" to 4, + "0.0.0.0" to 4, + "16.0.0.0" to 6, + "20.0.0.0" to 10, + "20.64.0.0" to 12, + "20.80.0.0" to 13, + "20.88.0.0" to 14, + "20.92.0.0" to 16, + "20.93.0.0" to 18, + "20.93.64.0" to 21, + "20.93.72.0" to 22, + "20.93.76.0" to 24, + "20.93.77.0" to 27, + // Excluded range: 20.93.77.32 -> 20.93.77.32 + "20.93.77.33" to 32, + "20.93.77.34" to 31, + "20.93.77.36" to 30, + "20.93.77.40" to 29, + "20.93.77.48" to 28, + "20.93.77.64" to 26, + "20.93.77.128" to 25, + "20.93.78.0" to 23, + "20.93.80.0" to 20, + "20.93.96.0" to 19, + "20.93.128.0" to 17, + "20.94.0.0" to 15, + "20.96.0.0" to 11, + "20.128.0.0" to 10, + "20.192.0.0" to 11, + "20.224.0.0" to 12, + "20.240.0.0" to 13, + "20.248.0.0" to 14, + "20.252.0.0" to 16, + "20.253.0.0" to 20, + "20.253.16.0" to 21, + "20.253.24.0" to 23, + "20.253.26.0" to 26, + "20.253.26.64" to 27, + "20.253.26.96" to 28, + // Excluded range: 20.253.26.112 -> 20.253.26.112 + "20.253.26.113" to 32, + "20.253.26.114" to 31, + "20.253.26.116" to 30, + "20.253.26.120" to 29, + "20.253.26.128" to 25, + "20.253.27.0" to 24, + "20.253.28.0" to 22, + "20.253.32.0" to 19, + "20.253.64.0" to 18, + "20.253.128.0" to 17, + "20.254.0.0" to 15, + "21.0.0.0" to 8, + "22.0.0.0" to 7, + "24.0.0.0" to 5, "32.0.0.0" to 3, "64.0.0.0" to 3, "96.0.0.0" to 4, diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/VpnLocalDns.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/VpnLocalDns.kt new file mode 100644 index 000000000000..b98c37b55e40 --- /dev/null +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/VpnLocalDns.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.networkprotection.impl.configuration + +import com.duckduckgo.di.scopes.VpnScope +import com.duckduckgo.networkprotection.impl.VpnRemoteFeatures +import com.squareup.anvil.annotations.ContributesTo +import com.squareup.moshi.Moshi +import dagger.Module +import dagger.Provides +import dagger.SingleInstanceIn +import java.net.InetAddress +import javax.inject.Qualifier +import logcat.asLog +import logcat.logcat +import okhttp3.Dns + +interface VpnLocalDns : Dns + +internal const val CONTROLLER_NETP_DUCKDUCKGO_COM = "controller.netp.duckduckgo.com" +private const val VPN_EUN_CONTROLLER = "20.93.77.32" +private const val VPN_USE_CONTROLLER = "20.253.26.112" + +private class VpnLocalDnsImpl( + private val vpnRemoteFeatures: VpnRemoteFeatures, + moshi: Moshi, + private val defaultDns: Dns, +) : VpnLocalDns { + + private val adapter = moshi.adapter(LocalDnsSettings::class.java) + + /** + * String is the domain + * DnsEntry is the DNS entry corresponding to the domain + */ + private val localDomains: Map> by lazy { + getRemoteDnsEntries() + } + + private val fallbackDomains: Map> = mapOf( + CONTROLLER_NETP_DUCKDUCKGO_COM to listOf( + DnsEntry(VPN_USE_CONTROLLER, "use"), + DnsEntry(VPN_EUN_CONTROLLER, "eun"), + ), + ) + + override fun lookup(hostname: String): List { + logcat { "Lookup for $hostname" } + if (vpnRemoteFeatures.localVpnControllerDns().isEnabled() == false) { + return defaultDns.lookup(hostname) + } + + return try { + defaultDns.lookup(hostname) + } catch (t: Throwable) { + val localResolution = localDomains[hostname] ?: fallbackDomains[hostname] + localResolution?.let { entries -> + logcat { "Hardcoded DNS for $hostname" } + + entries.map { InetAddress.getByName(it.address) } + } ?: throw t + } + } + + private fun getRemoteDnsEntries(): Map> { + vpnRemoteFeatures.localVpnControllerDns().getSettings()?.let { settings -> + return try { + adapter.fromJson(settings)?.domains + } catch (t: Throwable) { + logcat { "Error parsing localDNS settings: ${t.asLog()}" } + null + }.orEmpty() + } + + return emptyMap() + } + + /* + "settings": { + "domains": { + "controller.netp.duckduckgo.com": [ + { + "address": "1.2.3.4", + "region": "use" + }, + { + "address": "1.2.2.2", + "region": "eun" + } + ] + } + } + */ + + private data class LocalDnsSettings( + val domains: Map>, + ) + + private data class DnsEntry( + val address: String, + val region: String, + ) +} + +@ContributesTo(VpnScope::class) +@Module +object VpnLocalDnsModule { + @Retention(AnnotationRetention.BINARY) + @Qualifier + private annotation class InternalApi + + @Provides + @SingleInstanceIn(VpnScope::class) + fun provideVpnLocalDns( + vpnRemoteFeatures: VpnRemoteFeatures, + moshi: Moshi, + @InternalApi defaultDns: Dns, + ): VpnLocalDns { + return VpnLocalDnsImpl(vpnRemoteFeatures, moshi, defaultDns) + } + + @Provides + @InternalApi + fun provideDefaultDns(): Dns { + return Dns.SYSTEM + } +} diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/WgServerApi.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/WgServerApi.kt index d0b066278cab..e097ac9f2acc 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/WgServerApi.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/WgServerApi.kt @@ -23,7 +23,6 @@ import com.duckduckgo.di.scopes.VpnScope import com.duckduckgo.networkprotection.impl.configuration.WgServerApi.Mode import com.duckduckgo.networkprotection.impl.configuration.WgServerApi.Mode.FailureRecovery import com.duckduckgo.networkprotection.impl.configuration.WgServerApi.WgServerData -import com.duckduckgo.networkprotection.impl.di.UnprotectedVpnControllerService import com.duckduckgo.networkprotection.impl.settings.geoswitching.NetpEgressServersProvider import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject @@ -55,7 +54,7 @@ interface WgServerApi { @ContributesBinding(VpnScope::class) class RealWgServerApi @Inject constructor( - @UnprotectedVpnControllerService private val wgVpnControllerService: WgVpnControllerService, + private val wgVpnControllerService: WgVpnControllerService, private val serverDebugProvider: WgServerDebugProvider, private val netNetpEgressServersProvider: NetpEgressServersProvider, private val appBuildConfig: AppBuildConfig, @@ -83,7 +82,9 @@ class RealWgServerApi @Inject constructor( null } - val userPreferredLocation = netNetpEgressServersProvider.updateServerLocationsAndReturnPreferred() + val userPreferredLocation = netNetpEgressServersProvider.updateServerLocationsAndReturnPreferred( + wgVpnControllerService.getEligibleLocations(), + ) val registerKeyBody = if (mode is FailureRecovery) { RegisterKeyBody(publicKey = publicKey, server = mode.currentServer, mode = mode.toString()) } else if (selectedServer != null) { diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/WgTunnel.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/WgTunnel.kt index eaaa5e6996a2..164156328a33 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/WgTunnel.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/WgTunnel.kt @@ -38,6 +38,7 @@ import java.net.InetAddress import javax.inject.Inject import javax.inject.Qualifier import logcat.LogPriority +import logcat.LogPriority.ERROR import logcat.asLog import logcat.logcat @@ -212,7 +213,11 @@ class RealWgTunnel @Inject constructor( null } - val serverData = wgServerApi.registerPublicKey(publicKey, mode = mode) ?: throw NullPointerException("serverData = null") + val serverData = kotlin.runCatching { + wgServerApi.registerPublicKey(publicKey, mode = mode) ?: throw NullPointerException("serverData = null") + }.onFailure { + logcat(ERROR) { "Error registering public key" } + }.getOrThrow() return Config.Builder() .setInterface( diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/WgVpnControllerService.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/WgVpnControllerService.kt index 79c3808ccc99..61b5a8a2876d 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/WgVpnControllerService.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/WgVpnControllerService.kt @@ -17,26 +17,15 @@ package com.duckduckgo.networkprotection.impl.configuration import android.annotation.SuppressLint -import com.duckduckgo.anvil.annotations.ContributesServiceApi -import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.di.scopes.VpnScope -import com.duckduckgo.mobile.android.vpn.service.VpnSocketProtector -import com.duckduckgo.networkprotection.impl.di.ProtectedVpnControllerService -import com.duckduckgo.networkprotection.impl.di.UnprotectedVpnControllerService import com.squareup.anvil.annotations.ContributesTo import dagger.Lazy import dagger.Module import dagger.Provides import dagger.SingleInstanceIn -import java.security.KeyStore -import java.security.Security import javax.inject.Named import javax.inject.Qualifier -import javax.net.ssl.SSLSocket -import javax.net.ssl.TrustManagerFactory -import javax.net.ssl.X509TrustManager import okhttp3.OkHttpClient -import org.conscrypt.Conscrypt import retrofit2.Retrofit import retrofit2.http.Body import retrofit2.http.GET @@ -57,58 +46,28 @@ object WgVpnControllerServiceModule { @SingleInstanceIn(VpnScope::class) fun provideInternalCustomHttpClient( @Named("api") okHttpClient: OkHttpClient, - delegatingSSLSocketFactory: DelegatingSSLSocketFactory, + vpnLocalDns: VpnLocalDns, ): OkHttpClient { - val trustManagerFactory = TrustManagerFactory.getInstance( - TrustManagerFactory.getDefaultAlgorithm(), - ) - trustManagerFactory.init(null as KeyStore?) - val trustManagers = trustManagerFactory.trustManagers - check(!(trustManagers.size != 1 || trustManagers[0] !is X509TrustManager)) { - ("Unexpected default trust managers: ${trustManagers.contentToString()}") - } - val trustManager = trustManagers[0] as X509TrustManager - return okHttpClient.newBuilder() - .sslSocketFactory(delegatingSSLSocketFactory, trustManager) + .dns(vpnLocalDns) .build() } @Provides @SingleInstanceIn(VpnScope::class) - fun provideDelegatingSSLSocketFactory( - socketProtector: Lazy, - @Named("api") okHttpClient: Lazy, - ): DelegatingSSLSocketFactory { - return object : DelegatingSSLSocketFactory(okHttpClient.get().sslSocketFactory) { - override fun configureSocket(sslSocket: SSLSocket): SSLSocket { - socketProtector.get().protect(sslSocket) - return sslSocket - } - } - } - - @Provides - @UnprotectedVpnControllerService - @SingleInstanceIn(VpnScope::class) @SuppressLint("NoRetrofitCreateMethodCallDetector") fun providesWgVpnControllerService( @Named(value = "api") retrofit: Retrofit, @InternalApi customClient: Lazy, ): WgVpnControllerService { - val customRetrofit = retrofit.newBuilder() + return retrofit.newBuilder() .callFactory { customClient.get().newCall(it) } .build() - - // insertProviderAt trick to avoid error during handshakes - Security.insertProviderAt(Conscrypt.newProvider(), 1) - - return customRetrofit.create(WgVpnControllerService::class.java) + .create(WgVpnControllerService::class.java) } } -@ContributesServiceApi(AppScope::class) -@ProtectedVpnControllerService +// We need to provide a custom OkHttp, that's why we don't use the [ContributesServiceApi] annotation interface WgVpnControllerService { @GET("$NETP_ENVIRONMENT_URL/servers") @AuthRequired diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/di/ProtectedVpnControllerService.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/di/ProtectedVpnControllerService.kt deleted file mode 100644 index cb867dc443f1..000000000000 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/di/ProtectedVpnControllerService.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (c) 2020 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.networkprotection.impl.di - -import javax.inject.Qualifier - -/** Identifies a production version of the config toggles DAO */ -@Qualifier -@MustBeDocumented -@Retention(AnnotationRetention.RUNTIME) -annotation class ProtectedVpnControllerService diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/di/UnprotectedVpnControllerService.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/di/UnprotectedVpnControllerService.kt deleted file mode 100644 index f34d5f6debca..000000000000 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/di/UnprotectedVpnControllerService.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (c) 2020 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.networkprotection.impl.di - -import javax.inject.Qualifier - -/** Identifies a production version of the config toggles DAO */ -@Qualifier -@MustBeDocumented -@Retention(AnnotationRetention.RUNTIME) -annotation class UnprotectedVpnControllerService diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/failure/ServerMigrationMonitor.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/failure/ServerMigrationMonitor.kt index 3d216a6cf14d..00687d6b315f 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/failure/ServerMigrationMonitor.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/failure/ServerMigrationMonitor.kt @@ -25,7 +25,6 @@ import com.duckduckgo.networkprotection.api.NetworkProtectionState import com.duckduckgo.networkprotection.impl.configuration.WgTunnelConfig import com.duckduckgo.networkprotection.impl.configuration.WgVpnControllerService import com.duckduckgo.networkprotection.impl.configuration.asServerDetails -import com.duckduckgo.networkprotection.impl.di.ProtectedVpnControllerService import com.duckduckgo.networkprotection.impl.pixels.NetworkProtectionPixels import com.squareup.anvil.annotations.ContributesMultibinding import javax.inject.Inject @@ -39,7 +38,7 @@ import logcat.logcat @ContributesMultibinding(VpnScope::class) class ServerMigrationMonitor @Inject constructor( - @ProtectedVpnControllerService private val wgVpnControllerService: WgVpnControllerService, + private val wgVpnControllerService: WgVpnControllerService, private val wgTunnelConfig: WgTunnelConfig, private val networkProtectionState: NetworkProtectionState, private val dispatcherProvider: DispatcherProvider, diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/pixels/WireguardHandshakeMonitor.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/pixels/WireguardHandshakeMonitor.kt index e5cd76658c3e..0c2a8066f948 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/pixels/WireguardHandshakeMonitor.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/pixels/WireguardHandshakeMonitor.kt @@ -35,6 +35,7 @@ import com.duckduckgo.networkprotection.impl.pixels.WireguardHandshakeMonitor.Li import com.squareup.anvil.annotations.ContributesMultibinding import java.time.Instant import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -67,6 +68,7 @@ class WireguardHandshakeMonitor @Inject constructor( private val job = ConflatedJob() private val failureReported = AtomicBoolean(false) + private val attemptsWithZeroHandshakeEpoc = AtomicInteger(0) override fun onVpnStarted(coroutineScope: CoroutineScope) { job += coroutineScope.launch { @@ -90,13 +92,17 @@ class WireguardHandshakeMonitor @Inject constructor( private suspend fun startHandShakeMonitoring() = withContext(dispatcherProvider.io()) { if (networkProtectionState.isEnabled()) { - failureReported.set(false) + // failureReported.set(false) currentNetworkState.start() + attemptsWithZeroHandshakeEpoc.set(0) while (isActive && networkProtectionState.isEnabled()) { val nowSeconds = Instant.now().epochSecond val lastHandshakeEpocSeconds = wgProtocol.getStatistics().lastHandshakeEpochSeconds - if (lastHandshakeEpocSeconds > 0) { + logcat { "Handshake monitoring $lastHandshakeEpocSeconds, attempts ${attemptsWithZeroHandshakeEpoc.get()}" } + if (lastHandshakeEpocSeconds > 0 || attemptsWithZeroHandshakeEpoc.compareAndSet(MAX_ATTEMPTS_WITH_NO_HS_EPOC, 0)) { + attemptsWithZeroHandshakeEpoc.set(0) // reset in case lastHandshakeEpocSeconds > 0 + val diff = nowSeconds - lastHandshakeEpocSeconds if (diff.seconds.inWholeMinutes > REPORT_TUNNEL_FAILURE_IN_THRESHOLD_MINUTES && currentNetworkState.isConnected()) { logcat(WARN) { "Last handshake was more than 5 minutes ago" } @@ -118,6 +124,8 @@ class WireguardHandshakeMonitor @Inject constructor( } } } + } else { + attemptsWithZeroHandshakeEpoc.incrementAndGet() } delay(1.minutes.inWholeMilliseconds) } @@ -128,6 +136,10 @@ class WireguardHandshakeMonitor @Inject constructor( // WG handshakes happen every 2min, this means we'd miss 2+ handshakes private const val REPORT_TUNNEL_FAILURE_IN_THRESHOLD_MINUTES = 5 + // We test every 1min, try recovery after 4min + // WG handshakes happen every 2min, this mean 4min without handshakes + private const val MAX_ATTEMPTS_WITH_NO_HS_EPOC = 4 + // WG handshakes happen every 2min private const val REPORT_TUNNEL_FAILURE_RECOVERY_THRESHOLD_MINUTES = 2 } diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/NetpEgressServersProvider.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/NetpEgressServersProvider.kt index 17e3ba5ba699..17ae9b90ea2b 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/NetpEgressServersProvider.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/NetpEgressServersProvider.kt @@ -18,8 +18,7 @@ package com.duckduckgo.networkprotection.impl.settings.geoswitching import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.networkprotection.impl.configuration.WgVpnControllerService -import com.duckduckgo.networkprotection.impl.di.ProtectedVpnControllerService +import com.duckduckgo.networkprotection.impl.configuration.EligibleLocation import com.duckduckgo.networkprotection.impl.settings.geoswitching.NetpEgressServersProvider.PreferredLocation import com.duckduckgo.networkprotection.impl.settings.geoswitching.NetpEgressServersProvider.ServerLocation import com.duckduckgo.networkprotection.store.NetPGeoswitchingRepository @@ -29,7 +28,7 @@ import javax.inject.Inject import kotlinx.coroutines.withContext interface NetpEgressServersProvider { - suspend fun updateServerLocationsAndReturnPreferred(): PreferredLocation? + suspend fun updateServerLocationsAndReturnPreferred(eligibleLocations: List): PreferredLocation? suspend fun getServerLocations(): List data class ServerLocation( @@ -46,12 +45,13 @@ interface NetpEgressServersProvider { @ContributesBinding(AppScope::class) class RealNetpEgressServersProvider @Inject constructor( - @ProtectedVpnControllerService private val wgVpnControllerService: WgVpnControllerService, private val dispatcherProvider: DispatcherProvider, private val netPGeoswitchingRepository: NetPGeoswitchingRepository, ) : NetpEgressServersProvider { - override suspend fun updateServerLocationsAndReturnPreferred(): PreferredLocation? = withContext(dispatcherProvider.io()) { - val serverLocations = wgVpnControllerService.getEligibleLocations() + override suspend fun updateServerLocationsAndReturnPreferred(eligibleLocations: List): PreferredLocation? = withContext( + dispatcherProvider.io(), + ) { + val serverLocations = eligibleLocations .map { location -> NetPGeoswitchingLocation( countryCode = location.country, diff --git a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/RealWgServerApiTest.kt b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/RealWgServerApiTest.kt index 1a05cf8bb8d1..fb9bbdaaf99e 100644 --- a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/RealWgServerApiTest.kt +++ b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/RealWgServerApiTest.kt @@ -152,19 +152,20 @@ class RealWgServerApiTest { fun whenRegisterInProductionThenDownloadGeoswitchingData() = runTest { productionApi.registerPublicKey("testpublickey") - verify(netpEgressServersProvider).updateServerLocationsAndReturnPreferred() + verify(netpEgressServersProvider).updateServerLocationsAndReturnPreferred(wgVpnControllerService.getEligibleLocations()) } @Test fun whenRegisterInInternalThenDownloadGeoswitchingData() = runTest { internalApi.registerPublicKey("testpublickey") - verify(netpEgressServersProvider).updateServerLocationsAndReturnPreferred() + verify(netpEgressServersProvider).updateServerLocationsAndReturnPreferred(wgVpnControllerService.getEligibleLocations()) } @Test fun whenUserPreferredCountrySetThenRegisterPublicKeyShouldRequestForCountry() = runTest { - whenever(netpEgressServersProvider.updateServerLocationsAndReturnPreferred()).thenReturn(PreferredLocation("nl")) + whenever(netpEgressServersProvider.updateServerLocationsAndReturnPreferred(wgVpnControllerService.getEligibleLocations())) + .thenReturn(PreferredLocation("nl")) assertEquals( WgServerData( @@ -181,7 +182,7 @@ class RealWgServerApiTest { @Test fun whenUserPreferredLocationSetThenRegisterPublicKeyShouldRequestForCountryAndCity() = runTest { - whenever(netpEgressServersProvider.updateServerLocationsAndReturnPreferred()).thenReturn( + whenever(netpEgressServersProvider.updateServerLocationsAndReturnPreferred(wgVpnControllerService.getEligibleLocations())).thenReturn( PreferredLocation(countryCode = "us", cityName = "Des Moines"), ) @@ -202,7 +203,7 @@ class RealWgServerApiTest { fun whenUserPreferredLocationSetAndInternalDebugServerSelectedThenRegisterPublicKeyShouldReturnDebugServer() = runTest { whenever(appBuildConfig.flavor).thenReturn(INTERNAL) internalWgServerDebugProvider.selectedServer = "egress.euw.2" - whenever(netpEgressServersProvider.updateServerLocationsAndReturnPreferred()).thenReturn( + whenever(netpEgressServersProvider.updateServerLocationsAndReturnPreferred(wgVpnControllerService.getEligibleLocations())).thenReturn( PreferredLocation(countryCode = "us", cityName = "Des Moines"), ) diff --git a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/VpnLocalDnsImplTest.kt b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/VpnLocalDnsImplTest.kt new file mode 100644 index 000000000000..4666a0807efb --- /dev/null +++ b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/VpnLocalDnsImplTest.kt @@ -0,0 +1,114 @@ +package com.duckduckgo.networkprotection.impl.configuration + +import android.annotation.SuppressLint +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.networkprotection.impl.VpnRemoteFeatures +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import java.net.InetAddress +import junit.framework.TestCase.assertEquals +import okhttp3.Dns +import org.junit.Assert +import org.junit.Test + +@SuppressLint("DenyListedApi") // setRawStoredState +class VpnLocalDnsImplTest { + + private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + private val defaultDns = object : Dns { + override fun lookup(hostname: String): List { + TODO("Not yet implemented") + } + } + private val defaultState = Toggle.State( + remoteEnableState = false, + settings = """ + { + "domains": { + "controller.netp.duckduckgo.com": [ + { + "address": "1.2.3.4", + "region": "use" + }, + { + "address": "1.2.2.2", + "region": "eun" + } + ] + } + } + """.trimIndent(), + ) + private val vpnRemoteFeatures = FakeFeatureToggleFactory.create(VpnRemoteFeatures::class.java).apply { + localVpnControllerDns().setRawStoredState(defaultState) + } + private val vpnLocalDns = VpnLocalDnsModule.provideVpnLocalDns(vpnRemoteFeatures, moshi, defaultDns) + + @Test(expected = NotImplementedError::class) + fun `lookup uses default DNS when feature is disabled and looking up domains other than VPN controller`() { + vpnRemoteFeatures.localVpnControllerDns().setRawStoredState(defaultState.copy(remoteEnableState = false)) + + assertEquals(emptyList(), vpnLocalDns.lookup("not-controller.netp.duckduckgo.com")) + } + + @Test(expected = NotImplementedError::class) + fun `lookup uses default DNS when feature is disabled and looking up VPN controller domains`() { + vpnRemoteFeatures.localVpnControllerDns().setRawStoredState(defaultState.copy(remoteEnableState = false)) + + assertEquals(emptyList(), vpnLocalDns.lookup("controller.netp.duckduckgo.com")) + } + + @Test(expected = NotImplementedError::class) + fun `lookup uses default DNS when feature is enabled and looking up domains other than VPN controller`() { + vpnRemoteFeatures.localVpnControllerDns().setRawStoredState(defaultState.copy(remoteEnableState = true)) + + assertEquals(emptyList(), vpnLocalDns.lookup("not-controller.netp.duckduckgo.com")) + } + + @Test + fun `lookup uses in-app DNS when feature is enabled and looking up VPN controller domains`() { + vpnRemoteFeatures.localVpnControllerDns().setRawStoredState(defaultState.copy(remoteEnableState = true)) + + assertEquals( + listOf(InetAddress.getByName("1.2.3.4"), InetAddress.getByName("1.2.2.2")), + vpnLocalDns.lookup("controller.netp.duckduckgo.com"), + ) + } + + @Test + fun `controller DNS lookup returns fallback when remote entries not available`() { + vpnRemoteFeatures.localVpnControllerDns().setRawStoredState(Toggle.State(remoteEnableState = true, settings = "")) + + val dnsResponse = vpnLocalDns.lookup("controller.netp.duckduckgo.com").map { it.hostAddress } + val expected = listOf("20.253.26.112", "20.93.77.32") + + Assert.assertEquals(expected, dnsResponse) + } + + @Test + fun `controller DNS lookup returns fallback when remote entries not present`() { + vpnRemoteFeatures.localVpnControllerDns().setRawStoredState( + Toggle.State( + remoteEnableState = true, + settings = """ + { + "domains": { + "fake.controller.duckduckgo.com": [ + { + "address": "1.1.1.1", + "region": "aus" + } + ] + } + } + """.trimIndent(), + ), + ) + + val dnsResponse = vpnLocalDns.lookup("controller.netp.duckduckgo.com").map { it.hostAddress } + val expected = listOf("20.253.26.112", "20.93.77.32") + + Assert.assertEquals(expected, dnsResponse) + } +} diff --git a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/FakeNetpEgressServersProvider.kt b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/FakeNetpEgressServersProvider.kt index 34f42dff5b47..50e85deb3004 100644 --- a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/FakeNetpEgressServersProvider.kt +++ b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/FakeNetpEgressServersProvider.kt @@ -16,11 +16,12 @@ package com.duckduckgo.networkprotection.impl.settings.geoswitching +import com.duckduckgo.networkprotection.impl.configuration.EligibleLocation import com.duckduckgo.networkprotection.impl.settings.geoswitching.NetpEgressServersProvider.PreferredLocation import com.duckduckgo.networkprotection.impl.settings.geoswitching.NetpEgressServersProvider.ServerLocation class FakeNetpEgressServersProvider : NetpEgressServersProvider { - override suspend fun updateServerLocationsAndReturnPreferred(): PreferredLocation? { + override suspend fun updateServerLocationsAndReturnPreferred(eligibleLocations: List): PreferredLocation? { TODO("Not yet implemented") } diff --git a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/RealNetpEgressServersProviderTest.kt b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/RealNetpEgressServersProviderTest.kt index c99376f53047..887325e4acab 100644 --- a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/RealNetpEgressServersProviderTest.kt +++ b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/RealNetpEgressServersProviderTest.kt @@ -36,7 +36,7 @@ class RealNetpEgressServersProviderTest { var coroutineRule = CoroutineTestRule() private lateinit var testee: RealNetpEgressServersProvider - private var wgVpnControllerService = FakeWgVpnControllerService() + private val wgVpnControllerService = FakeWgVpnControllerService() private lateinit var netPGeoswitchingRepository: NetPGeoswitchingRepository @@ -44,7 +44,6 @@ class RealNetpEgressServersProviderTest { fun setUp() { netPGeoswitchingRepository = FakeNetPGeoswitchingRepository() testee = RealNetpEgressServersProvider( - wgVpnControllerService, coroutineRule.testDispatcherProvider, netPGeoswitchingRepository, ) @@ -52,7 +51,7 @@ class RealNetpEgressServersProviderTest { @Test fun whenDownloadDateThenParseAndReplaceStoredLocations() = runTest { - assertNull(testee.updateServerLocationsAndReturnPreferred()) + assertNull(testee.updateServerLocationsAndReturnPreferred(wgVpnControllerService.getEligibleLocations())) val expectedResult = listOf( NetPGeoswitchingLocation( countryCode = "nl", @@ -82,7 +81,10 @@ class RealNetpEgressServersProviderTest { ), ) - assertEquals(PreferredLocation(countryCode = "se", cityName = "Gothenburg"), testee.updateServerLocationsAndReturnPreferred()) + assertEquals( + PreferredLocation(countryCode = "se", cityName = "Gothenburg"), + testee.updateServerLocationsAndReturnPreferred(wgVpnControllerService.getEligibleLocations()), + ) val expectedResult = listOf( NetPGeoswitchingLocation( countryCode = "nl", @@ -112,7 +114,10 @@ class RealNetpEgressServersProviderTest { ), ) - assertEquals(PreferredLocation(countryCode = "se"), testee.updateServerLocationsAndReturnPreferred()) + assertEquals( + PreferredLocation(countryCode = "se"), + testee.updateServerLocationsAndReturnPreferred(wgVpnControllerService.getEligibleLocations()), + ) val expectedResult = listOf( NetPGeoswitchingLocation( countryCode = "nl", @@ -142,7 +147,7 @@ class RealNetpEgressServersProviderTest { ), ) - assertNull(testee.updateServerLocationsAndReturnPreferred()) + assertNull(testee.updateServerLocationsAndReturnPreferred(wgVpnControllerService.getEligibleLocations())) val expectedResult = listOf( NetPGeoswitchingLocation( countryCode = "nl", @@ -172,7 +177,7 @@ class RealNetpEgressServersProviderTest { ), ) - assertNull(testee.updateServerLocationsAndReturnPreferred()) + assertNull(testee.updateServerLocationsAndReturnPreferred(wgVpnControllerService.getEligibleLocations())) val expectedResult = listOf( NetPGeoswitchingLocation( countryCode = "nl", @@ -202,7 +207,10 @@ class RealNetpEgressServersProviderTest { ), ) - assertEquals(PreferredLocation(countryCode = "se", cityName = "Gothenburg"), testee.updateServerLocationsAndReturnPreferred()) + assertEquals( + PreferredLocation(countryCode = "se", cityName = "Gothenburg"), + testee.updateServerLocationsAndReturnPreferred(wgVpnControllerService.getEligibleLocations()), + ) val expectedResult = listOf( NetPGeoswitchingLocation( countryCode = "nl", diff --git a/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/NetPInternalSettingsActivity.kt b/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/NetPInternalSettingsActivity.kt index 29188047e401..1fb84f479538 100644 --- a/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/NetPInternalSettingsActivity.kt +++ b/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/NetPInternalSettingsActivity.kt @@ -53,6 +53,7 @@ import com.duckduckgo.networkprotection.store.NetPGeoswitchingRepository import com.duckduckgo.networkprotection.store.NetPGeoswitchingRepository.UserPreferredLocation import com.duckduckgo.networkprotection.store.remote_config.NetPServerRepository import com.google.android.material.snackbar.Snackbar +import com.wireguard.crypto.KeyPair import java.io.FileInputStream import java.text.SimpleDateFormat import java.util.* @@ -152,6 +153,7 @@ class NetPInternalSettingsActivity : DuckDuckGoActivity() { binding.overrideServerBackendSelector.isEnabled = isEnabled binding.overrideServerBackendSelector.setSecondaryText(serverRepository.getSelectedServer()?.name ?: AUTOMATIC) binding.forceRekey.isEnabled = isEnabled + binding.egressFailure.isEnabled = isEnabled if (isEnabled) { val wgConfig = wgTunnelConfig.getWgConfig() wgConfig?.`interface`?.addresses?.joinToString(", ") { it.toString() }?.let { @@ -234,7 +236,17 @@ class NetPInternalSettingsActivity : DuckDuckGoActivity() { binding.forceRekey.setClickListener { lifecycleScope.launch { - sendBroadcast(Intent(DebugRekeyReceiver.ACTION_FORCE_REKEY)) + Intent(DebugRekeyReceiver.ACTION_FORCE_REKEY).apply { + setPackage(this@NetPInternalSettingsActivity.packageName) + }.also { + sendBroadcast(it) + } + } + } + + binding.egressFailure.setClickListener { + lifecycleScope.launch { + modifyKeys() } } @@ -248,6 +260,23 @@ class NetPInternalSettingsActivity : DuckDuckGoActivity() { } } + private suspend fun modifyKeys() = withContext(dispatcherProvider.io()) { + val oldConfig = wgTunnelConfig.getWgConfig() + val newConfig = oldConfig?.builder?.let { config -> + val interfaceBuilder = config.interfaze?.builder?.apply { + this.setKeyPair(KeyPair()) + } + + config.setInterface(interfaceBuilder?.build()) + } + + if (newConfig != null) { + val config = newConfig.build() + wgTunnelConfig.setWgConfig(config) + networkProtectionState.restart() + } + } + private fun handleStagingInput(isOverrideEnabled: Boolean) { if (isOverrideEnabled) { binding.stagingEnvironment.show() diff --git a/network-protection/network-protection-internal/src/main/res/layout/activity_netp_internal_settings.xml b/network-protection/network-protection-internal/src/main/res/layout/activity_netp_internal_settings.xml index 2b9d3b683bdc..5cc703cfbfa6 100644 --- a/network-protection/network-protection-internal/src/main/res/layout/activity_netp_internal_settings.xml +++ b/network-protection/network-protection-internal/src/main/res/layout/activity_netp_internal_settings.xml @@ -110,6 +110,15 @@ app:primaryText="@string/netpForceRekey" app:showSwitch="false" /> + + + Connection Quality Enable PCAP recording Force Rekey + Simulate egress failure Unsafe Wi-Fi detection Get notified when connected to unsafe Wi-Fi without a VPN. Block Malware