Skip to content
Open
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
3 changes: 2 additions & 1 deletion core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ android {
}

dependencies {
testImplementation(libs.junit)
implementation(project(":common"))

implementation(libs.androidx.core)
Expand All @@ -75,4 +76,4 @@ androidComponents.onVariants { variant ->
}
}
}
}
}
34 changes: 34 additions & 0 deletions core/src/foss/golang/routeconfig/routes_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
8 changes: 5 additions & 3 deletions core/src/main/golang/native/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"unsafe"

"cfa/native/app"
"cfa/native/config"

"github.com/metacubex/mihomo/log"
)
Expand Down Expand Up @@ -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)
}
}
2 changes: 2 additions & 0 deletions core/src/main/golang/native/config/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,15 @@ func Load(path string) error {
hub.ApplyConfig(cfg)

app.ApplySubtitlePattern(rawCfg.ClashForAndroid.UiSubtitlePattern)
publishRouteExclusions(rawCfg.Tun.RouteExcludeAddress)

runtime.GC()

return nil
}

func LoadDefault() {
publishRouteExclusions(nil)
cfg, err := config.Parse([]byte{})
if err != nil {
panic(err.Error())
Expand Down
29 changes: 29 additions & 0 deletions core/src/main/golang/native/config/routes.go
Original file line number Diff line number Diff line change
@@ -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...)
}
48 changes: 48 additions & 0 deletions core/src/main/golang/native/config/routes_test.go
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = emptyList(),
) : Parcelable {
override fun writeToParcel(parcel: Parcel, flags: Int) {
Parcelizer.encodeToParcel(serializer(), parcel, this)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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())
}
}
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions service/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ plugins {
}

dependencies {
testImplementation(libs.junit)
implementation(project(":core"))
implementation(project(":common"))

Expand Down
63 changes: 38 additions & 25 deletions service/src/main/java/com/github/kr328/clash/service/TunService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.*
Expand All @@ -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)
Expand All @@ -44,8 +49,6 @@ class TunService : VpnService(), CoroutineScope by CoroutineScope(Dispatchers.De
install(SuspendModule(self))

try {
tun.open()

while (isActive) {
val quit = select<Boolean> {
close.onEvent {
Expand Down Expand Up @@ -120,9 +123,36 @@ class TunService : VpnService(), CoroutineScope by CoroutineScope(Dispatchers.De
runtime.requestGc()
}

private fun TunModule.open() {
private fun TunModule.open(exclusions: List<String>) {
val store = ServiceStore(self)

val includes = mutableListOf<String>()
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)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,7 +15,10 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.selects.select
import java.util.*

class ConfigurationModule(service: Service) : Module<ConfigurationModule.LoadException>(service) {
class ConfigurationModule(
service: Service,
private val onLoaded: (UiConfiguration) -> Unit = {},
) : Module<ConfigurationModule.LoadException>(service) {
data class LoadException(val message: String)

private val store = ServiceStore(service)
Expand Down Expand Up @@ -59,6 +63,9 @@ class ConfigurationModule(service: Service) : Module<ConfigurationModule.LoadExc

Clash.load(service.importedDir.resolve(active.uuid.toString())).await()

// Await the active snapshot before establishing or replacing Android VPN routes.
onLoaded(Clash.queryConfiguration())

val remove = SelectionDao().querySelections(active.uuid)
.filterNot { Clash.patchSelector(it.proxy, it.selected) }
.map { it.proxy }
Expand All @@ -71,6 +78,7 @@ class ConfigurationModule(service: Service) : Module<ConfigurationModule.LoadExc

Log.d("Profile ${active.name} loaded")
} catch (e: Exception) {
Log.w("Configuration load or VPN route application rejected")
return enqueueEvent(LoadException(e.message ?: "Unknown"))
}
}
Expand Down
Loading