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
6 changes: 6 additions & 0 deletions Helper/HelperTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,12 @@ class HelperTool: NSObject, HelperProtocol {
reply(HelperConstants.helperVersion)
}

func listTunnelOwners(withReply reply: @escaping (Bool, [[String: String]]) -> Void) {
// Pure read of process/socket metadata — it changes nothing, so there is no gateway or
// destination to validate here, unlike every mutating method on this protocol.
reply(true, TunnelOwnership.sweep().map(\.asDictionary))
}

private func logFlushOutcome(_ name: String, _ outcome: ProcessDeadline.Outcome) {
switch outcome {
case .failedToStart:
Expand Down
2 changes: 1 addition & 1 deletion Helper/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<key>CFBundleName</key>
<string>VPNBypassHelper</string>
<key>CFBundleShortVersionString</key>
<string>2.1.1</string>
<string>2.2.0</string>
<key>CFBundleVersion</key>
<string>13</string>
<key>SMAuthorizedClients</key>
Expand Down
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ build-helper:
-o $(HELPER_BUILD_DIR)/$(HELPER_ID)-arm64 \
Sources/VPNBypassCore/HelperProtocol.swift \
Sources/VPNBypassCore/RouteKernel.swift \
Sources/VPNBypassCore/TunnelOwnership.swift \
Helper/HelperTool.swift \
Helper/main.swift
@swiftc -O \
Expand All @@ -30,6 +31,7 @@ build-helper:
-o $(HELPER_BUILD_DIR)/$(HELPER_ID)-x86_64 \
Sources/VPNBypassCore/HelperProtocol.swift \
Sources/VPNBypassCore/RouteKernel.swift \
Sources/VPNBypassCore/TunnelOwnership.swift \
Helper/HelperTool.swift \
Helper/main.swift
@lipo -create \
Expand Down
28 changes: 28 additions & 0 deletions Sources/VPNBypassCore/HelperManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,34 @@ final class HelperManager: ObservableObject {
return nil
}

/// Which process owns each `utun`, via the helper.
///
/// `nil` means "could not ask" — helper absent, too old to know the method, or timed out.
/// An empty array means the sweep ran and found nothing, which is a real answer and must
/// clear the map: utun numbers are recycled, so holding a departed tunnel's label would
/// eventually apply it to an unrelated tunnel. Cosmetic either way — this never influences
/// which tunnel gets routed.
func tunnelOwners() async -> [TunnelOwner]? {
guard isHelperInstalled else { return nil }
let connection = getOrCreateConnection()
return await withXPCDeadline(seconds: xpcTimeout, fallback: [TunnelOwner]?.none) { once in
let proxy = connection.remoteObjectProxyWithErrorHandler { _ in
// An older helper simply has no such method; that is expected during the
// upgrade window and is not worth an error line on every status pass.
once.complete(nil)
} as? HelperProtocol

guard let helper = proxy else {
once.complete(nil)
return
}

helper.listTunnelOwners { ok, rows in
once.complete(ok ? rows.compactMap(TunnelOwner.init(dictionary:)) : nil)
}
}
}

// MARK: - Helper Installation

func installHelper() async -> Bool {
Expand Down
14 changes: 13 additions & 1 deletion Sources/VPNBypassCore/HelperProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ protocol HelperProtocol {
/// Get the installed helper version
/// - Parameter reply: Callback with version string
func getVersion(withReply reply: @escaping (String) -> Void)

/// Every `utun` on the machine with the process that created it, as plist dictionaries.
///
/// Read-only and privileged for one reason: the descriptor lists of root-owned VPN daemons
/// are unreadable to the app, which runs as the user. Measured on two machines, an
/// unprivileged sweep sees only Apple's own tunnels and never Tailscale, GlobalProtect or a
/// mesh VPN — the exact ones we need to name.
/// - Parameter reply: `(true, rows)` when the sweep ran — `rows` may legitimately be empty
/// on a machine with no tunnels. The flag exists so a caller can tell that apart from
/// "could not ask", which matters because utun numbers are recycled: keeping a stale map
/// would paint a departed tunnel's label onto whatever claims its number next.
func listTunnelOwners(withReply reply: @escaping (Bool, [[String: String]]) -> Void)
}

// MARK: - Helper Constants
Expand Down Expand Up @@ -123,7 +135,7 @@ struct HelperConstants {
// of waitUntilExit() with no deadline. A wedged child used to park the XPC thread for
// the life of the daemon after the app had already dropped the 30s hosts-update
// connection. Bumped so installed 2.1.0 helpers reinstall and pick this up.
static let helperVersion = "2.1.1"
static let helperVersion = "2.2.0"
static let bundleID = "com.geiserx.vpnbypass.helper"
static let hostMarkerStart = "# VPN-BYPASS-MANAGED - START"
static let hostMarkerEnd = "# VPN-BYPASS-MANAGED - END"
Expand Down
48 changes: 47 additions & 1 deletion Sources/VPNBypassCore/RouteManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ final class RouteManager: ObservableObject {
@Published private(set) var isApplyingRoutes = false
@Published var isConfigLoadFailed = false

/// Which process owns each `utun`, as the helper last reported it. Cosmetic only: it names
/// tunnels, and must never decide which one gets routed. Empty when the helper cannot be
/// asked, in which case labelling falls back to the interface-prefix guess.
private(set) var tunnelOwnersByInterface: [String: TunnelOwner] = [:]

/// True once shutdown has begun. Checked by the operation gate (so no new route
/// operation can start) and by the tracked batch-add wrapper (so an operation
/// already past the gate cannot push more routes into the kernel mid-teardown).
Expand Down Expand Up @@ -881,6 +886,12 @@ final class RouteManager: ObservableObject {
}

private func detectVPNInterface() async -> (connected: Bool, interface: String?, type: VPNType?) {
// Before anything derives a type or a label from the map. This is the one funnel every
// detection path goes through — the status timer, the startup apply, and the
// display-only pass — so refreshing here means the first labels after launch are the
// real ones rather than the interface-name guess.
await refreshTunnelOwners()

// First check for specific VPN processes to help identify type
let runningVPNType = await detectRunningVPNProcess()

Expand Down Expand Up @@ -1094,7 +1105,38 @@ final class RouteManager: ObservableObject {
}

/// Try to detect VPN type from interface characteristics
/// Refresh the tunnel→owner map from the helper.
private func refreshTunnelOwners() async {
tunnelOwnersByInterface = Self.mergedTunnelOwners(
previous: tunnelOwnersByInterface,
fetched: await HelperManager.shared.tunnelOwners()
)
}

/// What the map should become after a sweep.
///
/// `nil` means the helper could not be asked, so the previous map stands — dropping it
/// would make labels flicker between polls. An empty array is a real answer and must be
/// applied: utun numbers are recycled, so keeping a departed tunnel's entry would
/// eventually label an unrelated tunnel with it.
nonisolated static func mergedTunnelOwners(
previous: [String: TunnelOwner], fetched: [TunnelOwner]?
) -> [String: TunnelOwner] {
guard let fetched else { return previous }
return Dictionary(fetched.map { ($0.interface, $0) }, uniquingKeysWith: { first, _ in first })
}

private func detectVPNTypeFromInterface(_ iface: String) -> VPNType {
// Ask the OS who owns the tunnel before guessing from its name. Several product labels
// are already VPNType raw values, so a recognised owner names the type outright; the
// mesh VPNs have no case and stay .unknown, but their display label is still correct.
if let owner = tunnelOwnersByInterface[iface],
let label = TunnelOwnership.productLabel(processName: owner.processName,
executablePath: owner.executablePath),
let known = VPNType(rawValue: label) {
return known
}

// GlobalProtect typically uses gpd0 or specific utun
if iface.hasPrefix("gpd") {
return .globalProtect
Expand Down Expand Up @@ -1186,7 +1228,11 @@ final class RouteManager: ObservableObject {
for p in parsed {
guard p.isUp, !p.addresses.isEmpty else { continue }
let label: String
if p.isTailscale {
if let owner = tunnelOwnersByInterface[p.interface] {
// The kernel knows who created this tunnel; that beats every heuristic below,
// and is the only thing that can name a mesh VPN like NetBird at all.
label = TunnelOwnership.displayLabel(for: owner)
} else if p.isTailscale {
label = "Tailscale"
} else if vpnInterface == p.interface, let t = vpnType {
label = t.rawValue
Expand Down
216 changes: 216 additions & 0 deletions Sources/VPNBypassCore/TunnelOwnership.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
// TunnelOwnership.swift
// Ask the OS which process owns a tunnel, instead of guessing from its address.
//
// A `utun` is created by opening a kernel control socket named `com.apple.net.utun_control`,
// and the process that opened it holds that descriptor for the tunnel's whole life. macOS will
// tell us who that is: `proc_pidfdinfo(..., PROC_PIDFDSOCKETINFO)` returns `SOCKINFO_KERN_CTL`
// carrying the control name and its unit. That is an exact answer with no vendor knowledge, no
// CLI, and no network call — where an address is only ever a guess, because CGNAT 100.64/10 is
// shared ground between Tailscale, NetBird, Netmaker, Twingate, Zscaler and Cloudflare WARP.
//
// It has to run as root. Every VPN daemon that matters runs as uid 0, and an unprivileged
// caller cannot read their descriptor lists — measured at 206/783 and 322/725 processes denied
// on two machines, with every VPN tunnel in the denied set. So the sweep lives in the helper.

import Foundation
import Darwin

/// One tunnel and the process that created it.
public struct TunnelOwner: Equatable {
public let interface: String
public let pid: Int32
public let processName: String
public let executablePath: String

public init(interface: String, pid: Int32, processName: String, executablePath: String) {
self.interface = interface
self.pid = pid
self.processName = processName
self.executablePath = executablePath
}

/// XPC carries plists, not Swift structs.
public var asDictionary: [String: String] {
["interface": interface, "pid": String(pid),
"processName": processName, "executablePath": executablePath]
}

public init?(dictionary d: [String: String]) {
guard let interface = d["interface"], let pidText = d["pid"], let pid = Int32(pidText),
let processName = d["processName"] else { return nil }
self.init(interface: interface, pid: pid, processName: processName,
executablePath: d["executablePath"] ?? "")
}
}

public enum TunnelOwnership {

// MARK: - Pure decisions

/// Kernel control unit `N` is interface `utun(N-1)`.
///
/// Unit numbering is 1-based because unit 0 means "kernel, pick one for me"; interface
/// numbering is 0-based. Verified on real hardware rather than inferred: a process that
/// creates a utun and reads its own name back via `getsockopt(UTUN_OPT_IFNAME)` reported
/// `utun5` while this map saw its control unit as 6.
public static func interfaceName(forControlUnit unit: UInt32) -> String? {
guard unit >= 1 else { return nil }
return "utun\(unit - 1)"
}

/// Processes that hold a tunnel's control socket without being the thing that made it.
///
/// `nesessionmanager` is the NetworkExtension broker: it holds the same control unit as the
/// extension it started, so a tunnel legitimately has more than one holder. Observed on two
/// machines, where unit 3 was held by both Tailscale's network extension and this broker.
/// Attributing a tunnel to the broker would label every NE-based VPN identically.
public static let brokerProcessNames: Set<String> = ["nesessionmanager", "neagent"]

/// Collapse raw `(unit, pid, name, path)` rows into one owner per interface.
///
/// Brokers are dropped first. If that leaves nothing, the broker is kept rather than losing
/// the tunnel entirely — knowing a tunnel exists is worth more than knowing nothing. Ties
/// among real owners resolve to the lowest pid, so repeated calls agree with each other.
public static func resolve(
_ rows: [(unit: UInt32, pid: Int32, processName: String, executablePath: String)]
) -> [String: TunnelOwner] {
var byInterface: [String: [(pid: Int32, name: String, path: String)]] = [:]
for row in rows {
guard let iface = interfaceName(forControlUnit: row.unit) else { continue }
byInterface[iface, default: []].append((row.pid, row.processName, row.executablePath))
}

var out: [String: TunnelOwner] = [:]
for (iface, holders) in byInterface {
let real = holders.filter { !brokerProcessNames.contains($0.name) }
let pick = (real.isEmpty ? holders : real).min { $0.pid < $1.pid }
guard let pick else { continue }
out[iface] = TunnelOwner(interface: iface, pid: pick.pid,
processName: pick.name, executablePath: pick.path)
}
return out
}

/// A human product name for an owning process, or nil when we have nothing better to say
/// than the process name itself.
///
/// Matching is on the OWNER, which is a much stronger signal than the loose scan of the
/// whole process table it replaces: that one matched any command line containing
/// "cloudflare", so `cloudflared` — a common tunnel daemon that is not WARP — was reported
/// as Cloudflare WARP. Here the process being tested is the one holding the tunnel.
public static func productLabel(processName: String, executablePath: String) -> String? {
let haystack = (processName + " " + executablePath).lowercased()
// Ordered: the first match wins, so put the specific before the generic.
let table: [(needle: String, label: String)] = [
("io.tailscale", "Tailscale"),
("tailscaled", "Tailscale"),
("tailscale", "Tailscale"),
("netbird", "NetBird"),
("netmaker", "Netmaker"),
("netclient", "Netmaker"),
("twingate", "Twingate"),
("zerotier", "ZeroTier"),
("pangps", "GlobalProtect"),
("pangpa", "GlobalProtect"),
("globalprotect", "GlobalProtect"),
("anyconnect", "Cisco AnyConnect"),
("vpnagent", "Cisco AnyConnect"),
("secureclient", "Cisco AnyConnect"),
("forticlient", "Fortinet FortiClient"),
("zscaler", "Zscaler"),
("warp-svc", "Cloudflare WARP"),
("cloudflarewarp", "Cloudflare WARP"),
("openvpn", "OpenVPN"),
("wireguard", "WireGuard"),
("wg-go", "WireGuard"),
("mullvad", "Mullvad"),
("nordvpn", "NordVPN"),
("protonvpn", "Proton VPN"),
("expressvpn", "ExpressVPN"),
("pulsesecure", "Pulse Secure"),
("endpoint_security_vpn", "Check Point"),
]
for entry in table where haystack.contains(entry.needle) { return entry.label }
return nil
}

/// What to show for a tunnel: the product name when we recognise the owner, otherwise the
/// owning process's own name, which is still far better than "VPN (utun7)".
public static func displayLabel(for owner: TunnelOwner) -> String {
if let product = productLabel(processName: owner.processName,
executablePath: owner.executablePath) {
return product
}
let base = (owner.processName as NSString).lastPathComponent
return base.isEmpty ? "VPN (\(owner.interface))" : base
}

// MARK: - The privileged sweep

/// Every `utun` on the machine with the process that created it.
///
/// Returns only what the caller is allowed to see, so an unprivileged caller gets a short,
/// misleading list rather than an error — which is exactly why this belongs in the helper.
/// Costs 1.7–3.0 ms across ~750 processes on Apple silicon.
public static func sweep() -> [TunnelOwner] {
let byteCount = proc_listpids(UInt32(PROC_ALL_PIDS), 0, nil, 0)
guard byteCount > 0 else { return [] }
// Processes come and go between sizing and reading; over-allocate so a burst of new
// ones cannot truncate the list.
let capacity = Int(byteCount) / MemoryLayout<pid_t>.size + 64
var pids = [pid_t](repeating: 0, count: capacity)
let filled = pids.withUnsafeMutableBufferPointer { buf in
proc_listpids(UInt32(PROC_ALL_PIDS), 0, buf.baseAddress,
Int32(capacity * MemoryLayout<pid_t>.size))
}
guard filled > 0 else { return [] }
let count = min(Int(filled) / MemoryLayout<pid_t>.size, capacity)

var rows: [(unit: UInt32, pid: Int32, processName: String, executablePath: String)] = []
for index in 0..<count {
let pid = pids[index]
guard pid > 0 else { continue }
let fdSize = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0)
guard fdSize > 0 else { continue } // exited, or not ours to read
let fdCapacity = Int(fdSize) + MemoryLayout<proc_fdinfo>.size * 32
var fds = [proc_fdinfo](repeating: proc_fdinfo(), count: fdCapacity / MemoryLayout<proc_fdinfo>.size)
let fdFilled = fds.withUnsafeMutableBufferPointer { buf in
proc_pidinfo(pid, PROC_PIDLISTFDS, 0, buf.baseAddress, Int32(fdCapacity))
}
guard fdFilled > 0 else { continue }
let fdCount = min(Int(fdFilled) / MemoryLayout<proc_fdinfo>.size, fds.count)

for fdIndex in 0..<fdCount {
guard fds[fdIndex].proc_fdtype == UInt32(PROX_FDTYPE_SOCKET) else { continue }
var info = socket_fdinfo()
let size = Int32(MemoryLayout<socket_fdinfo>.size)
let read = proc_pidfdinfo(pid, fds[fdIndex].proc_fd, PROC_PIDFDSOCKETINFO, &info, size)
guard read == size, info.psi.soi_kind == SOCKINFO_KERN_CTL else { continue }
var kctl = info.psi.soi_proto.pri_kern_ctl
let name = withUnsafeBytes(of: &kctl.kcsi_name) { raw -> String in
let bytes = raw.bindMemory(to: CChar.self)
guard let base = bytes.baseAddress else { return "" }
return String(cString: base)
}
guard name == "com.apple.net.utun_control" else { continue }
rows.append((unit: kctl.kcsi_unit, pid: pid,
processName: processName(of: pid), executablePath: executablePath(of: pid)))
}
}
return resolve(rows).values.sorted { $0.interface < $1.interface }
}

private static func processName(of pid: pid_t) -> String {
var buffer = [CChar](repeating: 0, count: Int(2 * MAXCOMLEN) + 1)
let written = proc_name(pid, &buffer, UInt32(buffer.count))
return written > 0 ? String(cString: buffer) : ""
}

private static func executablePath(of pid: pid_t) -> String {
// PROC_PIDPATHINFO_MAXSIZE is a macro, so it does not survive into Swift; 4×PATH_MAX
// is what the header defines it as.
var buffer = [CChar](repeating: 0, count: 4 * Int(PATH_MAX))
let written = proc_pidpath(pid, &buffer, UInt32(buffer.count))
return written > 0 ? String(cString: buffer) : ""
}
}
Loading