fix(route): empty site list now means a full tunnel, not all-direct — DIRECT_ALL no longer leaves the proxy carrying nothing

This commit is contained in:
heaven 2026-06-16 22:00:12 +03:00
parent f2b234341d
commit 55dada7772
2 changed files with 19 additions and 0 deletions

View file

@ -101,6 +101,11 @@ enum class RouteDecision { PROXY, DIRECT }
* - [RoutingMode.PROXY_ALL]: everything proxied; matched sites go DIRECT.
* - [RoutingMode.DIRECT_ALL]: everything direct; matched sites go through the PROXY.
*
* With **no rules at all** the split is inert and everything is proxied (a full tunnel)
* this deliberately overrides DIRECT_ALL, whose literal "everything direct" would otherwise
* leave a connected proxy carrying nothing while the user still sees a green "proxy confirmed".
* The split only takes effect once there is at least one site to match against.
*
* Only domain destinations can match IP-literal / IPv6 / non-DNS flows have no domain
* to test, so they always take the global default (the documented coverage gap).
*/
@ -109,6 +114,8 @@ class SiteRouter(
private val matcher: SiteMatcher,
) {
fun decide(host: String, isDomain: Boolean): RouteDecision {
// No rules ⇒ full tunnel, never the degenerate all-direct of an empty DIRECT_ALL.
if (matcher.isEmpty) return RouteDecision.PROXY
val matched = isDomain && matcher.matches(host)
return when (mode) {
RoutingMode.PROXY_ALL -> if (matched) RouteDecision.DIRECT else RouteDecision.PROXY

View file

@ -107,6 +107,18 @@ class SiteRoutingTest {
assertEquals(RouteDecision.DIRECT, r.decide("93.184.216.34", isDomain = false))
}
@Test fun directAll_emptyList_fallsBackToFullTunnel() {
// The footgun guard: DIRECT_ALL with no rules must NOT send everything direct (a
// connected proxy carrying nothing). With no rules the split is inert = full tunnel.
val r = SiteRouter(RoutingMode.DIRECT_ALL, SiteMatcher.EMPTY)
assertFalse(r.hasRules)
assertEquals(RouteDecision.PROXY, r.decide("anything.com", isDomain = true))
assertEquals(RouteDecision.PROXY, r.decide("93.184.216.34", isDomain = false))
// Same via build() with no entries.
val b = SiteRouter.build(RoutingMode.DIRECT_ALL, emptySet(), GeositeSource.EMPTY)
assertEquals(RouteDecision.PROXY, b.decide("anything.com", isDomain = true))
}
// --- SiteRouter.build (entries -> rules) ---
@Test fun build_mixesPlainAndGeositeEntries() {