diff --git a/core/build.gradle.kts b/core/build.gradle.kts index b0b655f00e..5c6bbf5c33 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -49,6 +49,7 @@ android { } dependencies { + testImplementation(libs.junit) implementation(project(":common")) implementation(libs.androidx.core) @@ -75,4 +76,4 @@ androidComponents.onVariants { variant -> } } } -} \ No newline at end of file +} diff --git a/core/src/foss/golang/routeconfig/routes_test.go b/core/src/foss/golang/routeconfig/routes_test.go new file mode 100644 index 0000000000..aee61cfa84 --- /dev/null +++ b/core/src/foss/golang/routeconfig/routes_test.go @@ -0,0 +1,34 @@ +package routeconfig + +import ( + "testing" + + "github.com/metacubex/mihomo/config" +) + +// Host test of the actual pinned parser used by CMFA UnmarshalAndPatch. +// The CMFA native/config package itself imports Android-only platform code. +func TestActiveYamlRouteField(t *testing.T) { + raw, err := config.UnmarshalRawConfig([]byte("tun:\n route-exclude-address:\n - 192.0.2.0/24\n - 2001:db8::/32\n")) + if err != nil { + t.Fatal(err) + } + if len(raw.Tun.RouteExcludeAddress) != 2 || raw.Tun.RouteExcludeAddress[0].String() != "192.0.2.0/24" || raw.Tun.RouteExcludeAddress[1].String() != "2001:db8::/32" { + t.Fatal("typed parser lost the exclusion field") + } +} + +func TestBadYamlCidrRejects(t *testing.T) { + if _, err := config.UnmarshalRawConfig([]byte("tun:\n route-exclude-address:\n - invalid-prefix\n")); err == nil { + t.Fatal("malformed typed CIDR accepted") + } +} + +func TestAbsentAndEmptyYaml(t *testing.T) { + for _, yaml := range []string{"{}", "tun:\n route-exclude-address: []\n"} { + raw, err := config.UnmarshalRawConfig([]byte(yaml)) + if err != nil || len(raw.Tun.RouteExcludeAddress) != 0 { + t.Fatalf("empty/absent exclusion changed: %v", err) + } + } +} diff --git a/core/src/main/golang/native/app.go b/core/src/main/golang/native/app.go index 1c9ad60be3..ff2c0bac2c 100644 --- a/core/src/main/golang/native/app.go +++ b/core/src/main/golang/native/app.go @@ -8,6 +8,7 @@ import ( "unsafe" "cfa/native/app" + "cfa/native/config" "github.com/metacubex/mihomo/log" ) @@ -48,14 +49,15 @@ func notifyTimeZoneChanged(name C.c_string, offset C.int) { app.NotifyTimeZoneChanged(C.GoString(name), int(offset)) } - //export queryConfiguration func queryConfiguration() *C.char { - response := &struct{}{} + response := struct { + RouteExcludeAddress []string `json:"routeExcludeAddress"` + }{config.QueryRouteExclusions()} return marshalJson(&response) } func init() { app.ApplyContentContext(openRemoteContent) -} \ No newline at end of file +} diff --git a/core/src/main/golang/native/config/load.go b/core/src/main/golang/native/config/load.go index c6d19878c9..6a6cd85192 100644 --- a/core/src/main/golang/native/config/load.go +++ b/core/src/main/golang/native/config/load.go @@ -79,6 +79,7 @@ func Load(path string) error { hub.ApplyConfig(cfg) app.ApplySubtitlePattern(rawCfg.ClashForAndroid.UiSubtitlePattern) + publishRouteExclusions(rawCfg.Tun.RouteExcludeAddress) runtime.GC() @@ -86,6 +87,7 @@ func Load(path string) error { } func LoadDefault() { + publishRouteExclusions(nil) cfg, err := config.Parse([]byte{}) if err != nil { panic(err.Error()) diff --git a/core/src/main/golang/native/config/routes.go b/core/src/main/golang/native/config/routes.go new file mode 100644 index 0000000000..f7628c4899 --- /dev/null +++ b/core/src/main/golang/native/config/routes.go @@ -0,0 +1,29 @@ +package config + +import ( + "net/netip" + "sync" +) + +// Only the successfully applied configuration is visible to Android. +var routeExclusions struct { + sync.RWMutex + prefixes []string +} + +func publishRouteExclusions(prefixes []netip.Prefix) { + values := make([]string, len(prefixes)) + for i, prefix := range prefixes { + values[i] = prefix.Masked().String() + } + routeExclusions.Lock() + routeExclusions.prefixes = values + routeExclusions.Unlock() +} + +func QueryRouteExclusions() []string { + routeExclusions.RLock() + defer routeExclusions.RUnlock() + // Return [] rather than null, and never expose the shared backing array. + return append([]string{}, routeExclusions.prefixes...) +} diff --git a/core/src/main/golang/native/config/routes_test.go b/core/src/main/golang/native/config/routes_test.go new file mode 100644 index 0000000000..86205aa8ff --- /dev/null +++ b/core/src/main/golang/native/config/routes_test.go @@ -0,0 +1,48 @@ +package config + +import ( + "encoding/json" + "net/netip" + "sync" + "testing" +) + +func TestRouteSnapshotCopiesAndMasks(t *testing.T) { + prefixes := []netip.Prefix{netip.MustParsePrefix("192.0.2.7/24"), netip.MustParsePrefix("2001:db8::1/64")} + publishRouteExclusions(prefixes) + prefixes[0] = netip.MustParsePrefix("10.0.0.0/8") + got := QueryRouteExclusions() + if got[0] != "192.0.2.0/24" || got[1] != "2001:db8::/64" { + t.Fatal(got) + } + got[0] = "modified" + if QueryRouteExclusions()[0] != "192.0.2.0/24" { + t.Fatal("snapshot aliases caller memory") + } +} + +func TestRouteSnapshotClearsToJsonArray(t *testing.T) { + publishRouteExclusions([]netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}) + publishRouteExclusions(nil) + encoded, err := json.Marshal(QueryRouteExclusions()) + if err != nil || string(encoded) != "[]" { + t.Fatalf("%s %v", encoded, err) + } +} + +func TestConcurrentRouteSnapshot(t *testing.T) { + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + publishRouteExclusions([]netip.Prefix{netip.MustParsePrefix("::/0")}) + if got := QueryRouteExclusions(); len(got) != 1 || got[0] != "::/0" { + t.Error(got) + } + } + }() + } + wg.Wait() +} diff --git a/core/src/main/java/com/github/kr328/clash/core/model/UiConfiguration.kt b/core/src/main/java/com/github/kr328/clash/core/model/UiConfiguration.kt index 2ea843c33c..a3369ba5fd 100644 --- a/core/src/main/java/com/github/kr328/clash/core/model/UiConfiguration.kt +++ b/core/src/main/java/com/github/kr328/clash/core/model/UiConfiguration.kt @@ -6,7 +6,9 @@ import com.github.kr328.clash.core.util.Parcelizer import kotlinx.serialization.Serializable @Serializable -class UiConfiguration : Parcelable { +class UiConfiguration( + val routeExcludeAddress: List = emptyList(), +) : Parcelable { override fun writeToParcel(parcel: Parcel, flags: Int) { Parcelizer.encodeToParcel(serializer(), parcel, this) } diff --git a/core/src/test/java/com/github/kr328/clash/core/model/UiConfigurationTest.kt b/core/src/test/java/com/github/kr328/clash/core/model/UiConfigurationTest.kt new file mode 100644 index 0000000000..457ea406fd --- /dev/null +++ b/core/src/test/java/com/github/kr328/clash/core/model/UiConfigurationTest.kt @@ -0,0 +1,25 @@ +package com.github.kr328.clash.core.model + +import kotlinx.serialization.json.Json +import org.junit.Assert.* +import org.junit.Test + +class UiConfigurationTest { + @Test + fun absentFieldDefaultsToEmpty() { + assertTrue(Json.decodeFromString(UiConfiguration.serializer(), "{}").routeExcludeAddress.isEmpty()) + } + + @Test + fun snapshotBridgeDecodesBothFamilies() { + val config = Json.decodeFromString(UiConfiguration.serializer(), + """{"routeExcludeAddress":["192.0.2.0/24","2001:db8::/32"]}""") + assertEquals(listOf("192.0.2.0/24", "2001:db8::/32"), config.routeExcludeAddress) + } + + @Test + fun emptyFieldDefaultsToUpstreamRoutes() { + assertTrue(Json.decodeFromString(UiConfiguration.serializer(), + """{"routeExcludeAddress":[]}""").routeExcludeAddress.isEmpty()) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3190850eff..5c5b748ea3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,10 +16,12 @@ serialization = "1.3.3" kaidl = "1.15" room = "2.4.2" multiprocess = "1.0.0" +junit = "4.13.2" quickie = "1.11.0" androidx-activity-ktx = "1.9.0" [libraries] +junit = { module = "junit:junit", version.ref = "junit" } build-android = { module = "com.android.tools.build:gradle", version.ref = "agp" } build-kotlin-common = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } build-kotlin-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" } diff --git a/service/build.gradle.kts b/service/build.gradle.kts index caf639ec76..190fe6196e 100644 --- a/service/build.gradle.kts +++ b/service/build.gradle.kts @@ -6,6 +6,7 @@ plugins { } dependencies { + testImplementation(libs.junit) implementation(project(":core")) implementation(project(":common")) diff --git a/service/src/main/java/com/github/kr328/clash/service/TunService.kt b/service/src/main/java/com/github/kr328/clash/service/TunService.kt index 8550f8ab81..db174993fa 100644 --- a/service/src/main/java/com/github/kr328/clash/service/TunService.kt +++ b/service/src/main/java/com/github/kr328/clash/service/TunService.kt @@ -3,6 +3,7 @@ package com.github.kr328.clash.service import android.annotation.TargetApi import android.app.PendingIntent import android.content.Intent +import android.net.IpPrefix import android.net.ProxyInfo import android.net.VpnService import android.os.Build @@ -13,8 +14,9 @@ import com.github.kr328.clash.service.clash.clashRuntime import com.github.kr328.clash.service.clash.module.* import com.github.kr328.clash.service.model.AccessControlMode import com.github.kr328.clash.service.store.ServiceStore +import com.github.kr328.clash.service.util.VpnRoutePlanner +import com.github.kr328.clash.service.util.VpnRouteSession import com.github.kr328.clash.service.util.cancelAndJoinBlocking -import com.github.kr328.clash.service.util.parseCIDR import com.github.kr328.clash.service.util.sendClashStarted import com.github.kr328.clash.service.util.sendClashStopped import kotlinx.coroutines.* @@ -31,7 +33,10 @@ class TunService : VpnService(), CoroutineScope by CoroutineScope(Dispatchers.De val close = install(CloseModule(self)) val tun = install(TunModule(self)) - val config = install(ConfigurationModule(self)) + val routes = VpnRouteSession { tun.open(it) } + val config = install(ConfigurationModule(self) { configuration -> + routes.update(configuration.routeExcludeAddress) + }) val network = install(NetworkObserveModule(self)) if (store.dynamicNotification) @@ -44,8 +49,6 @@ class TunService : VpnService(), CoroutineScope by CoroutineScope(Dispatchers.De install(SuspendModule(self)) try { - tun.open() - while (isActive) { val quit = select { close.onEvent { @@ -120,9 +123,36 @@ class TunService : VpnService(), CoroutineScope by CoroutineScope(Dispatchers.De runtime.requestGc() } - private fun TunModule.open() { + private fun TunModule.open(exclusions: List) { val store = ServiceStore(self) + val includes = mutableListOf() + if (store.bypassPrivateNetwork) { + includes.addAll(resources.getStringArray(R.array.bypass_private_route)) + if (store.allowIpv6) { + includes.addAll(resources.getStringArray(R.array.bypass_private_route6)) + } + includes.add("$TUN_DNS/32") + if (store.allowIpv6) includes.add("$TUN_DNS6/128") + } else { + includes.add("$NET_ANY/0") + if (store.allowIpv6) includes.add("$NET_ANY6/0") + } + val plan = try { + VpnRoutePlanner.plan(includes, exclusions, Build.VERSION.SDK_INT >= 33) + } catch (e: IllegalArgumentException) { + // Planner errors contain fixed diagnostics, never raw configuration values. + Log.w("VPN_ROUTE_PLAN_REJECTED: ${e.message}") + throw e + } + Log.i("ROUTE_EXCLUDE_MODE=${plan.mode}") + Log.i("VPN_ROUTE_INCLUDE_COUNT=${plan.includes.size}") + Log.i("VPN_ROUTE_EXCLUDE_COUNT=${plan.requestedExcludes.size}") + plan.requestedExcludes.forEach { Log.i("VPN_ROUTE_EXCLUDE=$it") } + if (!store.allowIpv6 && plan.requestedExcludes.any { it.bits == 128 }) { + Log.w("VPN_ROUTE_IPV6_EXCLUDE_INACTIVE: IPv6 remains disabled") + } + val device = with(Builder()) { // Interface address addAddress(TUN_GATEWAY, TUN_SUBNET_PREFIX) @@ -131,26 +161,9 @@ class TunService : VpnService(), CoroutineScope by CoroutineScope(Dispatchers.De } // Route - if (store.bypassPrivateNetwork) { - resources.getStringArray(R.array.bypass_private_route).map(::parseCIDR).forEach { - addRoute(it.ip, it.prefix) - } - if (store.allowIpv6) { - resources.getStringArray(R.array.bypass_private_route6).map(::parseCIDR).forEach { - addRoute(it.ip, it.prefix) - } - } - - // Route of virtual DNS - addRoute(TUN_DNS, 32) - if (store.allowIpv6) { - addRoute(TUN_DNS6, 128) - } - } else { - addRoute(NET_ANY, 0) - if (store.allowIpv6) { - addRoute(NET_ANY6, 0) - } + plan.includes.forEach { addRoute(it.address, it.length) } + if (Build.VERSION.SDK_INT >= 33) { + plan.excludes.forEach { excludeRoute(IpPrefix(it.address, it.length)) } } // Access Control diff --git a/service/src/main/java/com/github/kr328/clash/service/clash/module/ConfigurationModule.kt b/service/src/main/java/com/github/kr328/clash/service/clash/module/ConfigurationModule.kt index 7de311aaa9..54688e6caa 100644 --- a/service/src/main/java/com/github/kr328/clash/service/clash/module/ConfigurationModule.kt +++ b/service/src/main/java/com/github/kr328/clash/service/clash/module/ConfigurationModule.kt @@ -4,6 +4,7 @@ import android.app.Service import com.github.kr328.clash.common.constants.Intents import com.github.kr328.clash.common.log.Log import com.github.kr328.clash.core.Clash +import com.github.kr328.clash.core.model.UiConfiguration import com.github.kr328.clash.service.StatusProvider import com.github.kr328.clash.service.data.ImportedDao import com.github.kr328.clash.service.data.SelectionDao @@ -14,7 +15,10 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.selects.select import java.util.* -class ConfigurationModule(service: Service) : Module(service) { +class ConfigurationModule( + service: Service, + private val onLoaded: (UiConfiguration) -> Unit = {}, +) : Module(service) { data class LoadException(val message: String) private val store = ServiceStore(service) @@ -59,6 +63,9 @@ class ConfigurationModule(service: Service) : Module, + val excludes: List, + val requestedExcludes: List, + val mode: Mode, + ) + + data class Prefix private constructor( + val network: BigInteger, + val length: Int, + val bits: Int, + ) { + val address: InetAddress + get() { + val raw = network.toByteArray() + val bytes = ByteArray(bits / 8) + val count = minOf(bytes.size, raw.size) + raw.copyInto(bytes, bytes.size - count, raw.size - count) + return if (bits == 128) Inet6Address.getByAddress(null, bytes, -1) + else InetAddress.getByAddress(bytes) + } + + fun contains(other: Prefix): Boolean = bits == other.bits && length <= other.length && + network == mask(other.network, length, bits) + + fun children(): List { + check(length < bits) + return listOf( + Prefix(network, length + 1, bits), + Prefix(network.setBit(bits - length - 1), length + 1, bits), + ) + } + + override fun toString(): String = "${address.hostAddress}/$length" + + companion object { + fun parse(value: String): Prefix { + // Never put untrusted configuration text in an error or resolve a hostname. + val parts = value.split('/') + require(parts.size == 2) { "Invalid VPN route CIDR" } + require(parts[1].isNotEmpty() && parts[1].all { it in '0'..'9' }) { + "Invalid VPN route prefix length" + } + val length = parts[1].toIntOrNull() + ?: throw IllegalArgumentException("Invalid VPN route prefix length") + val bytes = if (':' in parts[0]) ipv6(parts[0]) else ipv4(parts[0]) + val bits = bytes.size * 8 + require(length in 0..bits) { "Invalid VPN route prefix length" } + return Prefix(mask(BigInteger(1, bytes), length, bits), length, bits) + } + + private fun ipv4(value: String): ByteArray { + val parts = value.split('.') + require(parts.size == 4) { "Invalid VPN IPv4 address" } + return parts.map { + require(it.isNotEmpty() && it.length <= 3 && it.all { c -> c in '0'..'9' }) { + "Invalid VPN IPv4 address" + } + require(it.length == 1 || it[0] != '0') { "Invalid VPN IPv4 address" } + val octet = it.toInt() + require(octet in 0..255) { "Invalid VPN IPv4 address" } + octet.toByte() + }.toByteArray() + } + + private fun ipv6(value: String): ByteArray { + var text = value + if ('.' in text) { + val tail = ipv4(text.substringAfterLast(':')) + val high = ((tail[0].toInt() and 255) shl 8) or (tail[1].toInt() and 255) + val low = ((tail[2].toInt() and 255) shl 8) or (tail[3].toInt() and 255) + text = text.substringBeforeLast(':') + ":${high.toString(16)}:${low.toString(16)}" + } + val halves = text.split("::") + require(halves.size <= 2) { "Invalid VPN IPv6 address" } + fun words(part: String): List = if (part.isEmpty()) emptyList() else + part.split(':').map { + require(it.length in 1..4 && it.all { c -> c in "0123456789abcdefABCDEF" }) { + "Invalid VPN IPv6 address" + } + it.toInt(16) + } + val left = words(halves[0]) + val right = if (halves.size == 2) words(halves[1]) else emptyList() + val missing = 8 - left.size - right.size + require(if (halves.size == 2) missing > 0 else missing == 0) { "Invalid VPN IPv6 address" } + val groups = left + List(missing) { 0 } + right + return ByteArray(16) { i -> (groups[i / 2] shr (if (i % 2 == 0) 8 else 0)).toByte() } + } + + private fun mask(value: BigInteger, length: Int, bits: Int): BigInteger = + value.shiftRight(bits - length).shiftLeft(bits - length) + } + } + + fun plan(includes: List, excludes: List, native: Boolean): Plan { + require(includes.size <= MAX_ROUTES && excludes.size <= MAX_ROUTES) { + "VPN route input exceeds $MAX_ROUTES entries" + } + val original = includes.map(Prefix::parse) + // Preserve original order and duplicates when the field is absent or empty. + if (excludes.isEmpty()) return Plan(original, emptyList(), emptyList(), Mode.UNCHANGED) + val requested = compact(excludes.map(Prefix::parse)) + val base = compact(original) + // An exclusion must not implicitly enable a disabled address family. + val relevant = requested.filter { e -> base.any { it.contains(e) || e.contains(it) } } + if (native) { + // More-specific includes would otherwise override a broad native exclusion. + val routes = base.filterNot { include -> relevant.any { it.contains(include) } } + checkSize(routes.size + relevant.size) + return Plan(routes, relevant, requested, Mode.NATIVE) + } + var routes = base + for (exclude in relevant) { + val next = mutableListOf() + fun subtract(include: Prefix) { + when { + exclude.contains(include) -> Unit + include.contains(exclude) -> include.children().forEach(::subtract) + else -> { + checkSize(next.size + 1) + next.add(include) + } + } + } + routes.forEach(::subtract) + routes = next + } + return Plan(routes, emptyList(), requested, Mode.CIDR_COMPLEMENT) + } + + private fun checkSize(size: Int) { + require(size <= MAX_ROUTES) { "VPN route plan exceeds $MAX_ROUTES entries" } + } + + private fun compact(prefixes: List): List { + val result = mutableListOf() + prefixes.distinct().sortedWith(compareBy({ it.bits }, { it.length }, { it.network })).forEach { p -> + if (result.none { it.contains(p) }) result.add(p) + } + return result.sortedWith(compareBy({ it.bits }, { it.network }, { it.length })) + } +} diff --git a/service/src/main/java/com/github/kr328/clash/service/util/VpnRouteSession.kt b/service/src/main/java/com/github/kr328/clash/service/util/VpnRouteSession.kt new file mode 100644 index 0000000000..31fc6fe326 --- /dev/null +++ b/service/src/main/java/com/github/kr328/clash/service/util/VpnRouteSession.kt @@ -0,0 +1,12 @@ +package com.github.kr328.clash.service.util + +/** Used by the serial configuration load loop; commit state only after Builder application succeeds. */ +class VpnRouteSession(private val apply: (List) -> Unit) { + private var applied: List? = null + + fun update(exclusions: List) { + if (applied == exclusions) return + apply(exclusions) + applied = exclusions.toList() + } +} diff --git a/service/src/test/java/com/github/kr328/clash/service/util/VpnRoutePlannerTest.kt b/service/src/test/java/com/github/kr328/clash/service/util/VpnRoutePlannerTest.kt new file mode 100644 index 0000000000..91527286ab --- /dev/null +++ b/service/src/test/java/com/github/kr328/clash/service/util/VpnRoutePlannerTest.kt @@ -0,0 +1,206 @@ +package com.github.kr328.clash.service.util + +import com.github.kr328.clash.service.util.VpnRoutePlanner.Prefix +import org.junit.Assert.* +import org.junit.Test +import java.io.File +import java.math.BigInteger +import java.util.Random +import javax.xml.parsers.DocumentBuilderFactory + +class VpnRoutePlannerTest { + private val v4 = listOf("0.0.0.0/0") + private val dual = v4 + "::/0" + + private fun routed(plan: VpnRoutePlanner.Plan, address: String): Boolean { + val host = Prefix.parse("$address/${if (':' in address) 128 else 32}") + val include = plan.includes.filter { it.contains(host) }.maxOfOrNull { it.length } ?: -1 + val exclude = plan.excludes.filter { it.contains(host) }.maxOfOrNull { it.length } ?: -1 + return include >= 0 && include > exclude + } + + private fun both(includes: List = v4, excludes: List, check: (VpnRoutePlanner.Plan) -> Unit) { + listOf(false, true).forEach { check(VpnRoutePlanner.plan(includes, excludes, it)) } + } + + @Test + fun ipv4SubnetPreservesNeighbors() = both(excludes = listOf("198.18.0.0/16")) { + assertFalse(routed(it, "198.18.1.42")) + assertTrue(routed(it, "8.8.8.8")) + assertTrue(routed(it, "198.17.255.255")) + assertTrue(routed(it, "198.19.0.0")) + } + + @Test + fun ipv4HostExcludesOnlyTarget() = both(excludes = listOf("198.18.1.42/32")) { + assertFalse(routed(it, "198.18.1.42")) + assertTrue(routed(it, "198.18.1.41")) + assertTrue(routed(it, "198.18.1.43")) + } + + @Test + fun multipleExclusions() = both(excludes = listOf("10.0.0.0/8", "192.0.2.0/24")) { + assertFalse(routed(it, "10.1.2.3")) + assertFalse(routed(it, "192.0.2.255")) + assertTrue(routed(it, "192.0.3.0")) + } + + @Test + fun overlappingExclusions() = both(excludes = listOf("10.0.0.0/8", "10.1.0.0/16")) { + assertEquals(1, it.requestedExcludes.size) + assertFalse(routed(it, "10.2.0.1")) + } + + @Test + fun duplicatesAndHostBits() = both(excludes = listOf("192.0.2.9/24", "192.0.2.0/24")) { + assertEquals(listOf(Prefix.parse("192.0.2.0/24")), it.requestedExcludes) + } + + @Test + fun malformedRejectedWithoutEcho() { + val invalid = listOf("invalid.example/24", "1.2.3.4", "1.2.3.999/32", "1.2.3.4/-1", + "1.2.3.4/33", "1.2.3.4/+1", "01.2.3.4/8", "1.2.3.4/999999999999", + "::/129", "1::2::3/64", "fe80::1%wlan0/64", "gg::/32", "/0", ":::/64", + "1:2:3:4:5:6:7:8:9/64", "[::1]/128", "1.2.3.4/32/0") + for (bad in invalid) for (native in listOf(false, true)) { + val error = assertThrows(IllegalArgumentException::class.java) { + VpnRoutePlanner.plan(dual, listOf(bad), native) + } + assertFalse(error.message.orEmpty().contains(bad)) + } + } + + @Test + fun ipv6SubnetAndHost() = both(dual, listOf("2001:db8::/32", "2001:db9::1/128")) { + assertFalse(routed(it, "2001:db8:1234::1")) + assertFalse(routed(it, "2001:db9::1")) + assertTrue(routed(it, "2001:db9::2")) + assertTrue(routed(it, "8.8.8.8")) + } + + @Test + fun emptyPreservesOrderAndDuplicates() { + val routes = listOf("192.0.2.0/24", "0.0.0.0/0", "192.0.2.0/24", "::/0") + both(routes, emptyList()) { + assertEquals(routes.map(Prefix::parse), it.includes) + assertEquals(VpnRoutePlanner.Mode.UNCHANGED, it.mode) + assertTrue(it.excludes.isEmpty()) + } + } + + @Test + fun allFamilyExcluded() = both(dual + "172.19.0.2/32" + "fdfe:dcba:9876::2/128", dual) { + assertTrue(it.includes.isEmpty()) + assertFalse(routed(it, "172.19.0.2")) + assertFalse(routed(it, "fdfe:dcba:9876::2")) + } + + @Test + fun nativeBroadExclusionBeatsSpecificInclude() = both( + listOf("0.0.0.0/0", "172.19.0.2/32"), listOf("172.16.0.0/12") + ) { + assertFalse(routed(it, "172.19.0.2")) + assertTrue(routed(it, "172.15.255.255")) + } + + @Test + fun disabledFamilyIsNotEnabled() = both(v4, listOf("::/0")) { + assertEquals(v4.map(Prefix::parse), it.includes) + assertTrue(it.excludes.isEmpty()) + assertFalse(routed(it, "2001:db8::1")) + } + + @Test + fun disjointExclusionDoesNotWidenIncludes() = both(listOf("192.0.2.0/24"), listOf("10.0.0.0/8")) { + assertTrue(routed(it, "192.0.2.1")) + assertFalse(routed(it, "8.8.8.8")) + } + + @Test + fun exactCoverageAndNoOverlap() { + for ((include, exclude, expectedCount) in listOf( + Triple("0.0.0.0/0", "198.18.0.0/16", 16), + Triple("::/0", "2001:db8::1/128", 128) + )) { + val plan = VpnRoutePlanner.plan(listOf(include), listOf(exclude), false) + val excluded = Prefix.parse(exclude) + assertEquals(expectedCount, plan.includes.size) + plan.includes.forEachIndexed { index, p -> + assertFalse(p.contains(excluded) || excluded.contains(p)) + plan.includes.drop(index + 1).forEach { q -> assertFalse(p.contains(q) || q.contains(p)) } + } + val size = plan.includes.fold(BigInteger.ZERO) { sum, p -> sum + BigInteger.ONE.shiftLeft(p.bits - p.length) } + assertEquals(BigInteger.ONE.shiftLeft(excluded.bits) - BigInteger.ONE.shiftLeft(excluded.bits - excluded.length), size) + } + } + + @Test + fun exhaustiveSmallSpaceAndDeterminism() { + val excludes = listOf("192.0.2.16/28", "192.0.2.128/26", "192.0.2.255/32", "192.0.2.17/32") + val a = VpnRoutePlanner.plan(listOf("192.0.2.0/24"), excludes, false) + assertEquals(a, VpnRoutePlanner.plan(listOf("192.0.2.0/24"), excludes.reversed(), false)) + both(listOf("192.0.2.0/24"), excludes) { plan -> + (0..255).forEach { n -> assertEquals(n !in 16..31 && n !in 128..191 && n != 255, routed(plan, "192.0.2.$n")) } + } + } + + @Test + fun randomMembershipMatchesAddressSet() { + val random = Random(5) + repeat(20) { + val exclusions = List(8) { "${random.nextInt(256)}.${random.nextInt(256)}.0.0/${8 + random.nextInt(9)}" } + val masks = exclusions.map(Prefix::parse) + both(dual, exclusions) { plan -> + repeat(100) { + val address = List(4) { random.nextInt(256) }.joinToString(".") + val host = Prefix.parse("$address/32") + assertEquals(masks.none { it.contains(host) }, routed(plan, address)) + } + } + } + } + + @Test + fun boundedRouteGrowth() { + assertThrows(IllegalArgumentException::class.java) { + VpnRoutePlanner.plan(v4, List(513) { "192.0.2.1/32" }, false) + } + val fragmented = List(32) { "${it * 7}.1.2.3/32" } + assertThrows(IllegalArgumentException::class.java) { VpnRoutePlanner.plan(v4, fragmented, false) } + assertEquals(32, VpnRoutePlanner.plan(v4, fragmented, true).excludes.size) + } + + @Test + fun ipv4MappedIpv6RemainsIpv6() { + val p = Prefix.parse("::ffff:192.0.2.1/128") + assertEquals(128, p.bits) + assertEquals(16, p.address.address.size) + assertEquals(Prefix.parse("0:0:0:0:0:ffff:c000:201/128"), p) + } + + @Test + fun privateBypassResourcesAndDnsPreserved() { + val root = generateSequence(File(checkNotNull(System.getProperty("user.dir")))) { it.parentFile } + .first { File(it, "service/src/main/res/values/arrays.xml").exists() } + val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder() + .parse(File(root, "service/src/main/res/values/arrays.xml")) + val arrays = doc.getElementsByTagName("string-array") + val routes = mutableListOf() + for (i in 0 until arrays.length) { + val children = arrays.item(i).childNodes + for (j in 0 until children.length) if (children.item(j).nodeName == "item") routes.add(children.item(j).textContent) + } + routes.addAll(listOf("172.19.0.2/32", "fdfe:dcba:9876::2/128")) + both(routes, emptyList()) { assertEquals(routes.map(Prefix::parse), it.includes) } + both(routes, listOf("198.18.0.0/16", "2001:db8::/32")) { + assertFalse(routed(it, "10.1.2.3")) + assertFalse(routed(it, "172.16.1.1")) + assertFalse(routed(it, "fc00::1")) + assertFalse(routed(it, "198.18.1.42")) + assertFalse(routed(it, "2001:db8::1")) + assertTrue(routed(it, "172.19.0.2")) + assertTrue(routed(it, "fdfe:dcba:9876::2")) + assertTrue(routed(it, "8.8.8.8")) + } + } +} diff --git a/service/src/test/java/com/github/kr328/clash/service/util/VpnRouteSessionTest.kt b/service/src/test/java/com/github/kr328/clash/service/util/VpnRouteSessionTest.kt new file mode 100644 index 0000000000..a42874bd2e --- /dev/null +++ b/service/src/test/java/com/github/kr328/clash/service/util/VpnRouteSessionTest.kt @@ -0,0 +1,38 @@ +package com.github.kr328.clash.service.util + +import org.junit.Assert.* +import org.junit.Test + +class VpnRouteSessionTest { + @Test + fun initialEmptyConfigEstablishesOnce() { + var calls = 0 + val session = VpnRouteSession { calls++ } + assertEquals(0, calls) + session.update(emptyList()) + session.update(emptyList()) + assertEquals(1, calls) + } + + @Test + fun reloadChangesAndRemovalReplaceRoutes() { + val applied = mutableListOf>() + val session = VpnRouteSession { applied.add(it.toList()) } + val a = listOf("192.0.2.0/24") + val b = listOf("2001:db8::/32") + session.update(a) + session.update(a) + session.update(b) + session.update(emptyList()) + assertEquals(listOf(a, b, emptyList()), applied) + } + + @Test + fun failedApplyIsNotRemembered() { + var calls = 0 + val session = VpnRouteSession { if (++calls == 1) error("Builder rejected") } + assertThrows(IllegalStateException::class.java) { session.update(emptyList()) } + session.update(emptyList()) + assertEquals(2, calls) + } +}