Skip to content

Route VPN controller outside VPN #5811

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Apr 15, 2025
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
Original file line number Diff line number Diff line change
@@ -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<String, Int>(), result)
}

@Test
fun `test exclude nothing (full range should remain)`() {
val excludedRanges = emptyMap<String, Int>()
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ interface VpnRemoteFeatures {

@DefaultValue(false)
fun allowDnsBlockMalware(): Toggle

@DefaultValue(false)
fun localVpnControllerDns(): Toggle
}

@ContributesBinding(AppScope::class)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CIDR> {

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<CIDR> {
val listResult: MutableList<CIDR> = 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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<String> {
return mutableSetOf<String>().apply {
Expand All @@ -62,14 +68,45 @@ class RealNetPDefaultConfigProvider @Inject constructor(
}

override suspend fun routes(): Map<String, Int> = 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<Inet4Address>()
.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<Inet4Address>().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<Inet4Address>()
.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<Inet4Address>().mapNotNull { it.hostAddress }.forEach { ip ->
this[ip] = 32
}
Expand Down
Loading
Loading