Skip to content
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
70 changes: 59 additions & 11 deletions Sources/VPNBypassCore/ClassicRouteCompiler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,51 @@ import Foundation

enum ClassicRouteCompiler {

/// The bypass-all routes VPN Only installs: four /2s covering all of IPv4 via the local
/// gateway. NOT the classic 0.0.0.0/1 + 128.0.0.0/1 pair, deliberately: wg-quick and
/// OpenVPN's redirect-gateway def1 capture traffic by installing that exact pair
/// themselves, so claiming it did not add routes — it REPOINTED the VPN's own routes to
/// the local gateway (the helper converges an existing destination in place) and teardown
/// then DELETED them out from under the VPN (#103: VPN Only under WireGuard inverted, and
/// WireGuard could not connect while the app ran, both sides fighting over the same two
/// kernel entries). The /2 quartet is additive: longest-prefix beats the VPN's /1s without
/// touching them, listed destinations still win over /2 with their /32s and CIDRs, and
/// removing the quartet hands traffic straight back to the tunnel.
static let bypassAllCatchAlls: [String] = [
"0.0.0.0/2", "64.0.0.0/2", "128.0.0.0/2", "192.0.0.0/2",
]

/// The quartet minus every quarter an inverse CIDR claims (equals or covers). One kernel
/// entry exists per destination, and a broader listed CIDR must keep the whole space it
/// names on the VPN — installing a more-specific local /2 inside it would silently carve
/// that traffic back out of the tunnel. Shared by the compiler and the DNS refresh planner
/// so their ownership views can never diverge.
static func unclaimedCatchAlls(inverseCIDRs: [String]) -> [String] {
bypassAllCatchAlls.filter { quarter in
!inverseCIDRs.contains { covers($0, quarter) }
}
}

/// True when `outer` (a well-formed IPv4 CIDR) contains the whole of `inner`.
static func covers(_ outer: String, _ inner: String) -> Bool {
guard let o = RouteCIDR.parse(outer), let i = RouteCIDR.parse(inner),
o.prefixLength <= i.prefixLength,
let oAddr = ipv4Value(o.network), let iAddr = ipv4Value(i.network) else { return false }
let mask: UInt32 = o.prefixLength == 0 ? 0 : UInt32.max << (32 - UInt32(o.prefixLength))
return (oAddr & mask) == (iAddr & mask)
}

private static func ipv4Value(_ dotted: String) -> UInt32? {
let parts = dotted.split(separator: ".").compactMap { UInt32($0) }
guard parts.count == 4, parts.allSatisfy({ $0 <= 255 }) else { return nil }
return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]
}

/// The ownership `source` every bypass-all catch-all is recorded under. Stale-route
/// classification keys off destination AND this source, so a user's own route that merely
/// shares a catch-all destination string is never mistaken for one of ours.
static let catchAllSource = "VPN Only catch-all"

/// A kernel route to install (matches the caller's `routesToAdd` tuple, as a testable value).
struct Route: Equatable, Hashable {
let destination: String
Expand Down Expand Up @@ -69,8 +114,9 @@ enum ClassicRouteCompiler {
var allSourceEntries: [SourceEntry] = []
var seenSourceDests: Set<String> = [] // dedup (source, destination) ownership pairs

// VPN Only: catch-all through the local gateway (0.0.0.0/1 + 128.0.0.0/1 cover all IPv4
// with higher specificity than the default route), then inverse CIDRs through the VPN.
// VPN Only: bypass-all through the local gateway (the /2 quartet covers all IPv4 with
// higher specificity than both a default route AND a /1-pair full tunnel — see
// bypassAllCatchAlls), then inverse CIDRs through the VPN.
//
// INSTALL ORDER IS LEAK-CRITICAL. The helper writes this array strictly in order, so the
// catch-alls are deferred to the very END rather than emitted here. Installing them first
Expand All @@ -85,15 +131,6 @@ enum ClassicRouteCompiler {
// Same principle, opposite end: the catch-alls are the last thing on and the first thing off.
var deferredCatchAlls: [Route] = []
if isInverse {
deferredCatchAlls.append(Route(destination: "0.0.0.0/1", gateway: localGateway, isNetwork: true, source: "VPN Only catch-all"))
deferredCatchAlls.append(Route(destination: "128.0.0.0/1", gateway: localGateway, isNetwork: true, source: "VPN Only catch-all"))
seenDestinations.insert("0.0.0.0/1")
seenDestinations.insert("128.0.0.0/1")
allSourceEntries.append(SourceEntry(destination: "0.0.0.0/1", gateway: localGateway, source: "VPN Only catch-all"))
allSourceEntries.append(SourceEntry(destination: "128.0.0.0/1", gateway: localGateway, source: "VPN Only catch-all"))
seenSourceDests.insert("VPN Only catch-all|0.0.0.0/1")
seenSourceDests.insert("VPN Only catch-all|128.0.0.0/1")

for cidr in inverseCIDRs {
if !seenDestinations.contains(cidr) {
seenDestinations.insert(cidr)
Expand All @@ -105,6 +142,17 @@ enum ClassicRouteCompiler {
allSourceEntries.append(SourceEntry(destination: cidr, gateway: routeGateway, source: cidr))
}
}

// AFTER the inverse CIDRs, and only the quarters no listed CIDR claims: an exact
// /2 owns its kernel entry outright, and a broader CIDR (a /1) must keep every
// quarter inside it on the VPN — a more-specific local /2 would silently carve
// that traffic back out of the tunnel. Unclaimed quarters still go local.
for catchAll in unclaimedCatchAlls(inverseCIDRs: inverseCIDRs) where !seenDestinations.contains(catchAll) {
deferredCatchAlls.append(Route(destination: catchAll, gateway: localGateway, isNetwork: true, source: catchAllSource))
seenDestinations.insert(catchAll)
allSourceEntries.append(SourceEntry(destination: catchAll, gateway: localGateway, source: catchAllSource))
seenSourceDests.insert("\(catchAllSource)|\(catchAll)")
}
}

// Resolved domain / service-domain IPs (host routes) through the route gateway.
Expand Down
4 changes: 2 additions & 2 deletions Sources/VPNBypassCore/CommandRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -496,8 +496,8 @@ enum CommandRouter {
/// Validates a rule MATCH pattern (RuleResolver matches traffic IPs against it), NOT a
/// kernel route destination. `/0` is intentionally allowed here — it means "match any
/// IPv4" (a catch-all rule) — whereas RouteManager.isValidCIDR rejects `/0` for route
/// *destinations* because it collides with the VPN-Only `0.0.0.0/1`+`128.0.0.0/1`
/// catch-all. Different purposes; the `/0` difference is deliberate, not drift.
/// *destinations* because it collides with the VPN-Only bypass-all
/// catch-alls. Different purposes; the `/0` difference is deliberate, not drift.
private static func isValidCIDR(_ s: String) -> Bool {
let parts = s.split(separator: "/", omittingEmptySubsequences: false)
guard parts.count == 2, let bits = Int(parts[1]), (0...32).contains(bits) else { return false }
Expand Down
11 changes: 8 additions & 3 deletions Sources/VPNBypassCore/DNSRefreshPlanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ enum DNSRefreshPlanner {
/// - cachedDomainIPs: the disk-cache snapshot, consulted only for the DNS-failed fallback.
/// - existingDestinations: kernel destinations already present (never re-added).
/// - existingSourceDests: ownership pairs already tracked (never re-recorded).
/// - isInverse: `true` for VPN Only (seeds the two catch-alls), `false` for Bypass.
/// - isInverse: `true` for VPN Only (seeds the bypass-all catch-alls), `false` for Bypass.
/// - routeGateway: the gateway every refreshed host route + ownership row rides.
/// - inverseCIDRs: enabled inverse CIDR entries (VPN Only). Seeded into `expectedEntries` as
/// `(cidr, cidr)`, never DNS-resolved or added here — the caller repairs their kernel route.
Expand All @@ -93,8 +93,13 @@ enum DNSRefreshPlanner {
// Preserve catch-all routes in VPN Only mode (they aren't DNS-resolved), then seed the
// static inverse CIDR entries. Both are expected but never added by this planner.
if isInverse {
expectedEntries.insert(SourceDest(source: "VPN Only catch-all", destination: "0.0.0.0/1"))
expectedEntries.insert(SourceDest(source: "VPN Only catch-all", destination: "128.0.0.0/1"))
// Mirror the compiler's claim rules exactly: a quarter an inverse CIDR owns is
// never installed as a catch-all, so expecting it here would record a second
// ownership row for the same destination and make the CIDR's later removal skip
// the kernel delete ("another owner still wants it").
for catchAll in ClassicRouteCompiler.unclaimedCatchAlls(inverseCIDRs: inverseCIDRs) {
expectedEntries.insert(SourceDest(source: ClassicRouteCompiler.catchAllSource, destination: catchAll))
}
for cidr in inverseCIDRs {
// CIDR entries: preserve as static routes, no DNS resolution.
expectedEntries.insert(SourceDest(source: cidr, destination: cidr))
Expand Down
17 changes: 15 additions & 2 deletions Sources/VPNBypassCore/RouteCompiler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,16 @@ enum RouteCompiler {
/// trips its route monitor and tears the tunnel down (the original incident that
/// motivates the whole project). Custom mode is per-rule and should never produce
/// these, but the guard is the custom-engine analog of refuseVPNOnlyUnderGlobalProtect.
static let catchAllDestinations: Set<String> = ["0.0.0.0/0", "0.0.0.0/1", "128.0.0.0/1"]
/// The set carries everything this app has EVER installed as a bypass-all: the /2 quartet
/// VPN Only installs today, the 0.0.0.0/1 + 128.0.0.0/1 pair every build up to 4.8.0
/// installed (teardown and strand-sweeps must still recognise what an older build left
/// behind), and the custom 0.0.0.0/0. Cleanup ordering, unstranding and stale-route
/// recognition key off this set; the structural-shadow predicate below deliberately
/// does not.
static let catchAllDestinations: Set<String> = [
"0.0.0.0/0", "0.0.0.0/1", "128.0.0.0/1",
"0.0.0.0/2", "64.0.0.0/2", "128.0.0.0/2", "192.0.0.0/2",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
]

/// A destination that structurally shadows a full-tunnel VPN's default route.
/// The canonical trio, PLUS any CIDR with prefix length <= 1 (a /0 or /1 covers
Expand All @@ -198,7 +207,11 @@ enum RouteCompiler {
/// not a replacement, so it doesn't trip GP's route monitor — that's the user's
/// explicit choice, not a teardown vector. Covers IPv4 and IPv6 (::/0) alike.
static func isCatchAll(_ destination: String) -> Bool {
if catchAllDestinations.contains(destination) { return true }
// Deliberately NOT keyed off catchAllDestinations: that set now also carries the /2
// quartet VPN Only installs (and must clean up), and a /2 is additive, not a shadow —
// a user's explicit /2 rule under GlobalProtect stays allowed, exactly as before.
// For every input this is byte-identical to the old set-plus-prefix check: the old
// set's members all had prefix <= 1 themselves.
let parts = destination.split(separator: "/")
if parts.count == 2, let prefix = Int(parts[1]), prefix <= 1 { return true }
return false
Expand Down
28 changes: 28 additions & 0 deletions Sources/VPNBypassCore/RouteKernel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,34 @@ public enum RouteKernel {
return value
}

/// The tunnel that owns the 0.0.0.0/1 + 128.0.0.0/1 full-tunnel pair, if any.
///
/// wg-quick and OpenVPN's redirect-gateway def1 capture traffic by installing that pair and
/// leaving `default` on the physical link, so `route get default` — selection's usual ground
/// truth — keeps naming the physical interface while every packet actually enters the tunnel.
/// By longest-prefix match the /1 owner IS the traffic carrier. Our own routes are excluded
/// by their RTF_PROTO1 mark (and are /2s besides). Only interface-gatewayed (AF_LINK) rows
/// are attributable — an OpenVPN-style /1 via an AF_INET next hop carries no interface index
/// in the dump, and returning nil there falls back to today's behaviour.
public static func slashOneTunnelOwnerIndex(_ table: [KernelRoute]) -> UInt16? {
for route in table where !route.isOurs && route.prefix == 1 {
guard route.destination == 0 || route.destination == 0x8000_0000 else { continue }
if let index = route.gatewayInterfaceIndex { return index }
}
return nil
}

/// `slashOneTunnelOwnerIndex` resolved to a name, kept only when it names a tunnel-class
/// interface — a /1 pinned to a physical link is not a VPN and must not steer selection.
public static func slashOneTunnelOwner(_ table: [KernelRoute]) -> String? {
guard let index = slashOneTunnelOwnerIndex(table) else { return nil }
var buffer = [CChar](repeating: 0, count: Int(IFNAMSIZ) + 1)
guard if_indextoname(UInt32(index), &buffer) != nil else { return nil }
let name = String(cString: buffer)
let tunnelPrefixes = ["utun", "tun", "tap", "ppp", "ipsec"]
return tunnelPrefixes.contains(where: { name.hasPrefix($0) }) ? name : nil
}

static func dotted(_ ip: UInt32) -> String {
"\((ip >> 24) & 0xff).\((ip >> 16) & 0xff).\((ip >> 8) & 0xff).\(ip & 0xff)"
}
Expand Down
Loading