feat: native split-tunneling for xray (#1899)

* feat: integrated xray as a library and added split-tunneling

* fix: added copying amnezia_xray.dll to build dir

* fix: changed path on darwin

* chore: clean up getting default device

* chore: removed WSAGetLastError from sockopt logging

* fix: get rid of debug logs in xray handlers

* fix: minor fixes and xray debugging capabilities

* fix: macos default interface fix

* fix: roll-back ipv6 sockopt for mac

* fix: bind IPv6 on Windows

* fix: (win) better IPv6 handling and router fixes

* feat: prebuilts uploaded

* fix: removed redundant cmake definitions

* feat: moved xray to service process, reworked errors

* fix: return values in networkUtilities

* fix: macos build fixes

* fix: (windows) cmake fixes

* fix: (windows) compilation fix

* fix: (windows) changed location of amnezia_xray.dll

* feat: xray logs added to system service

* chore: bump xray&tun2socks versions for android

* chore: cleanup of XrayProtocol class
* removed killswitch
* removed redundant members and basic cleanup

* feat: support split-tunneling in iOS and macOS NE

* chore: update active interface index based on network path and available interfaces

* refactor: update network path handling and logging in PacketTunnelProvider

* chore: bump xray deps

---------

Co-authored-by: Yaroslav Yashin <yaroslav.yashin@gmail.com>
This commit is contained in:
Yaroslav Gurov
2025-12-15 14:54:34 +01:00
committed by GitHub
parent d669adb707
commit 54f67b3d82
21 changed files with 455 additions and 207 deletions
@@ -131,7 +131,7 @@ extension PacketTunnelProvider {
}
startHandler = completionHandler
ovpnAdapter?.connect(using: packetFlow)
ovpnAdapter?.connect(using: openVPNPacketFlow())
}
func handleOpenVPNStatusMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)? = nil) {
@@ -153,7 +153,7 @@ extension PacketTunnelProvider {
}
func stopOpenVPN(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
ovpnLog(.info, message: "Stopping tunnel: reason: \(reason.description)")
ovpnLog(.info, message: "Stopping tunnel: reason: \(reason.amneziaDescription)")
stopHandler = completionHandler
if vpnReachability.isTracking {
@@ -293,5 +293,3 @@ extension PacketTunnelProvider: OpenVPNAdapterDelegate {
ovpnLog(.info, message: logMessage)
}
}
extension NEPacketTunnelFlow: OpenVPNAdapterPacketFlow {}
@@ -176,7 +176,7 @@ extension PacketTunnelProvider {
}
func stopWireguard(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
wg_log(.info, message: "Stopping tunnel: reason: \(reason.description)")
wg_log(.info, message: "Stopping tunnel: reason: \(reason.amneziaDescription)")
wgAdapter?.stop { error in
ErrorNotifier.removeLastErrorFile()
@@ -107,6 +107,8 @@ extension PacketTunnelProvider {
return
}
self?.updateActiveInterfaceIndexForCurrentPath()
// Launch xray
self?.setupAndStartXray(configData: updatedData) { xrayError in
if let xrayError {
@@ -133,6 +135,15 @@ extension PacketTunnelProvider {
completionHandler()
}
func sockCallback(fd: uintptr_t) {
if activeIfaceIdx != 0 {
withUnsafePointer(to: activeIfaceIdx) { ptr in
setsockopt(Int32(fd), IPPROTO_IP, IP_BOUND_IF, ptr, socklen_t(MemoryLayout<UInt32>.size))
setsockopt(Int32(fd), IPPROTO_IPV6, IPV6_BOUND_IF, ptr, socklen_t(MemoryLayout<UInt32>.size))
}
}
}
private func setupAndStartXray(configData: Data,
completionHandler: @escaping (Error?) -> Void) {
let path = Constants.cachesDirectory.appendingPathComponent("config.json", isDirectory: false).path
@@ -142,6 +153,17 @@ extension PacketTunnelProvider {
return
}
updateActiveInterfaceIndexForCurrentPath()
let ctx = Unmanaged.passUnretained(self).toOpaque()
let cb: libxray_sockcallback = { (fd, ctx) in
guard let ctx = ctx else { return }
let instance = Unmanaged<PacketTunnelProvider>.fromOpaque(ctx).takeUnretainedValue()
instance.sockCallback(fd: fd)
}
LibXraySetSockCallback(cb, ctx)
LibXrayRunXray(nil,
path,
Int64.max)
+148 -15
View File
@@ -1,5 +1,6 @@
import Foundation
import NetworkExtension
import Network
import os
import Darwin
import OpenVPNAdapter
@@ -38,6 +39,12 @@ struct Constants {
class PacketTunnelProvider: NEPacketTunnelProvider {
var wgAdapter: WireGuardAdapter?
var ovpnAdapter: OpenVPNAdapter?
private lazy var openVPNPacketFlowAdapter = PacketTunnelFlowAdapter(flow: packetFlow)
private let pathMonitorQueue = DispatchQueue(label: Constants.processQueueName + ".path-monitor")
private let pathMonitor = NWPathMonitor()
private var didReceiveInitialPathUpdate = false
private var currentPath: Network.NWPath?
private var currentPathSignature: String?
var splitTunnelType: Int?
var splitTunnelSites: [String]?
@@ -47,6 +54,73 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
var startHandler: ((Error?) -> Void)?
var stopHandler: (() -> Void)?
var protoType: TunnelProtoType?
var activeIfaceIdx: UInt32 = 0
func openVPNPacketFlow() -> OpenVPNAdapterPacketFlow {
openVPNPacketFlowAdapter
}
override init() {
super.init()
pathMonitor.pathUpdateHandler = { [weak self] path in
guard let self else { return }
self.currentPath = path
let signature = self.pathSignature(for: path)
let hasMeaningfulChange = self.currentPathSignature != signature
self.currentPathSignature = signature
self.updateActiveInterfaceIndex(for: path)
guard self.didReceiveInitialPathUpdate else {
self.didReceiveInitialPathUpdate = true
return
}
guard hasMeaningfulChange, self.protoType != nil else { return }
DispatchQueue.main.async {
self.handle(networkChange: path) { _ in }
}
}
pathMonitor.start(queue: pathMonitorQueue)
currentPath = pathMonitor.currentPath
currentPathSignature = pathSignature(for: pathMonitor.currentPath)
}
func updateActiveInterfaceIndex(for path: Network.NWPath?) {
guard let path else {
activeIfaceIdx = 0
return
}
let preferredTypes: [NWInterface.InterfaceType] = [.wiredEthernet, .wifi, .cellular, .other]
let nonLoopbackInterfaces = path.availableInterfaces.filter { $0.type != .loopback }
let activeInterfaces = nonLoopbackInterfaces.filter { path.usesInterfaceType($0.type) }
let candidate = preferredTypes.compactMap { type in
activeInterfaces.first { $0.type == type }
}.first ?? activeInterfaces.first ?? nonLoopbackInterfaces.first
if let candidate {
activeIfaceIdx = UInt32(candidate.index)
} else {
activeIfaceIdx = 0
}
}
func updateActiveInterfaceIndexForCurrentPath() {
if let currentPath {
currentPathSignature = pathSignature(for: currentPath)
updateActiveInterfaceIndex(for: currentPath)
return
}
currentPath = pathMonitor.currentPath
currentPathSignature = pathSignature(for: pathMonitor.currentPath)
updateActiveInterfaceIndex(for: pathMonitor.currentPath)
}
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)? = nil) {
guard let message = String(data: messageData, encoding: .utf8) else {
@@ -104,6 +178,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
return
}
didReceiveInitialPathUpdate = false
updateActiveInterfaceIndexForCurrentPath()
switch protoType {
case .wireguard:
startWireguard(activationAttemptId: activationAttemptId,
@@ -157,28 +234,63 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
of object: Any?,
change: [NSKeyValueChangeKey: Any]?,
context: UnsafeMutableRawPointer?) {
guard Constants.kDefaultPathKey != keyPath else { return }
// Since iOS 11, we have observed that this KVO event fires repeatedly when connecting over Wifi,
// even though the underlying network has not changed (i.e. `isEqualToPath` returns false),
// leading to "wakeup crashes" due to excessive network activity. Guard against false positives by
// comparing the paths' string description, which includes properties not exposed by the class
guard let lastPath: NWPath = change?[.oldKey] as? NWPath,
let defPath = defaultPath,
lastPath != defPath || lastPath.description != defPath.description else {
guard Constants.kDefaultPathKey == keyPath else {
return
}
DispatchQueue.main.async { [weak self] in
guard let self, self.defaultPath != nil else { return }
self.handle(networkChange: self.defaultPath!) { _ in }
}
}
private func handle(networkChange changePath: NWPath, completion: @escaping (Error?) -> Void) {
private func handle(networkChange changePath: Network.NWPath, completion: @escaping (Error?) -> Void) {
updateActiveInterfaceIndex(for: changePath)
wg_log(.info, message: "Tunnel restarted.")
startTunnel(options: nil, completionHandler: completion)
}
}
private extension PacketTunnelProvider {
func pathSignature(for path: Network.NWPath) -> String {
var signatureComponents = [String(describing: path.status)]
signatureComponents.append(path.isExpensive ? "exp" : "noexp")
signatureComponents.append(path.isConstrained ? "con" : "nocon")
let preferredTypes: [NWInterface.InterfaceType] = [.wiredEthernet, .wifi, .cellular, .loopback, .other]
let sortedInterfaces = path.availableInterfaces.sorted { lhs, rhs in
if lhs.type == rhs.type {
return lhs.index < rhs.index
}
let lhsOrder = preferredTypes.firstIndex(of: lhs.type) ?? preferredTypes.count
let rhsOrder = preferredTypes.firstIndex(of: rhs.type) ?? preferredTypes.count
if lhsOrder == rhsOrder {
return lhs.index < rhs.index
}
return lhsOrder < rhsOrder
}
for interface in sortedInterfaces {
let typeName: String
switch interface.type {
case .wiredEthernet: typeName = "ethernet"
case .wifi: typeName = "wifi"
case .cellular: typeName = "cellular"
case .loopback: typeName = "loopback"
case .other: typeName = "other"
@unknown default: typeName = "unknown"
}
signatureComponents.append("\(typeName):\(interface.index)")
}
// Include currently used interface preference ordering
for type in preferredTypes {
let usesType = path.usesInterfaceType(type)
signatureComponents.append("uses-\(type):\(usesType)")
}
return signatureComponents.joined(separator: "|")
}
}
extension WireGuardLogLevel {
var osLogLevel: OSLogType {
switch self {
@@ -190,8 +302,27 @@ extension WireGuardLogLevel {
}
}
extension NEProviderStopReason: CustomStringConvertible {
public var description: String {
final class PacketTunnelFlowAdapter: NSObject, OpenVPNAdapterPacketFlow {
private let flow: NEPacketTunnelFlow
init(flow: NEPacketTunnelFlow) {
self.flow = flow
super.init()
}
@objc(readPacketsWithCompletionHandler:)
func readPackets(completionHandler: @escaping ([Data], [NSNumber]) -> Void) {
flow.readPackets(completionHandler: completionHandler)
}
@objc(writePackets:withProtocols:)
func writePackets(_ packets: [Data], withProtocols protocols: [NSNumber]) -> Bool {
flow.writePackets(packets, withProtocols: protocols)
}
}
extension NEProviderStopReason {
var amneziaDescription: String {
switch self {
case .none:
return "No specific reason"
@@ -223,6 +354,8 @@ extension NEProviderStopReason: CustomStringConvertible {
return "The current console user changed"
case .connectionFailed:
return "The connection failed"
case .internalError:
return "The network extension reported an internal error"
case .sleep:
return "A stop reason indicating the VPNC enabled disconnect on sleep and the device went to sleep"
case .appUpdate: