From c93cc4b9053bf1fdcd7755c093b11b8adc293c5a Mon Sep 17 00:00:00 2001 From: zy860 Date: Tue, 18 Aug 2026 15:06:10 +0800 Subject: [PATCH] fix: harden embedded vpn lifecycle --- StikDebug.xcodeproj/project.pbxproj | 10 +++ StikDebugTunnel/PacketTunnelProvider.swift | 34 +++++----- StikJIT/Tunnel/IPv4PacketRewriter.swift | 6 +- StikJIT/Utilities/DeviceTransport.swift | 3 + StikJIT/Utilities/IdeviceFFIBridge.swift | 10 +++ StikJIT/Utilities/JITEnableContext.swift | 13 +++- StikJIT/Utilities/StikDebugVPNManager.swift | 74 ++++++++++++++------- StikJIT/Utilities/StikDebugVPNStatus.swift | 8 +-- StikJITTests/StikJITTests.swift | 12 ++++ 9 files changed, 122 insertions(+), 48 deletions(-) diff --git a/StikDebug.xcodeproj/project.pbxproj b/StikDebug.xcodeproj/project.pbxproj index e28285d..4eb65dc 100644 --- a/StikDebug.xcodeproj/project.pbxproj +++ b/StikDebug.xcodeproj/project.pbxproj @@ -95,8 +95,18 @@ path = StikJITUITests; sourceTree = ""; }; + A4B2C3D40123456789ABC002 /* Exceptions for "StikDebugTunnel" folder in "StikDebugTunnel" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = A5B2C3D40123456789ABC001 /* StikDebugTunnel */; + }; A4B2C3D40123456789ABC001 /* StikDebugTunnel */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + A4B2C3D40123456789ABC002 /* Exceptions for "StikDebugTunnel" folder in "StikDebugTunnel" target */, + ); path = StikDebugTunnel; sourceTree = ""; }; diff --git a/StikDebugTunnel/PacketTunnelProvider.swift b/StikDebugTunnel/PacketTunnelProvider.swift index 1edd98a..e3043b7 100644 --- a/StikDebugTunnel/PacketTunnelProvider.swift +++ b/StikDebugTunnel/PacketTunnelProvider.swift @@ -4,6 +4,7 @@ // import Darwin +import Foundation import NetworkExtension final class PacketTunnelProvider: NEPacketTunnelProvider { @@ -73,25 +74,24 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { packetFlow.readPackets { [weak self] packets, protocols in guard let self, self.packetLoopActive else { return } - var rewrittenPackets = packets - for index in rewrittenPackets.indices { - guard protocols[index].int32Value == AF_INET else { continue } - rewrittenPackets[index].withUnsafeMutableBytes { rawBuffer in - guard let baseAddress = rawBuffer.baseAddress else { return } - var packet = Array( - UnsafeRawBufferPointer( - start: baseAddress, - count: rawBuffer.count - ) - ) - IPv4PacketRewriter.swapEndpoints(in: &packet) - packet.withUnsafeBytes { rewrittenBuffer in - rawBuffer.copyBytes(from: rewrittenBuffer) - } - } + var rewrittenPackets: [Data] = [] + var rewrittenProtocols: [NSNumber] = [] + for (packet, proto) in zip(packets, protocols) { + guard proto.int32Value == AF_INET else { continue } + + var bytes = [UInt8](packet) + guard IPv4PacketRewriter.isIPv4Packet(bytes) else { continue } + IPv4PacketRewriter.swapEndpoints(in: &bytes) + rewrittenPackets.append(Data(bytes)) + rewrittenProtocols.append(proto) } - self.packetFlow.writePackets(rewrittenPackets, withProtocols: protocols) + if !rewrittenPackets.isEmpty { + self.packetFlow.writePackets( + rewrittenPackets, + withProtocols: rewrittenProtocols + ) + } self.readPackets() } } diff --git a/StikJIT/Tunnel/IPv4PacketRewriter.swift b/StikJIT/Tunnel/IPv4PacketRewriter.swift index e8ed021..7601334 100644 --- a/StikJIT/Tunnel/IPv4PacketRewriter.swift +++ b/StikJIT/Tunnel/IPv4PacketRewriter.swift @@ -4,8 +4,12 @@ // enum IPv4PacketRewriter { + static func isIPv4Packet(_ packet: [UInt8]) -> Bool { + packet.count >= 20 && packet[0] >> 4 == 4 + } + static func swapEndpoints(in packet: inout [UInt8]) { - guard packet.count >= 20, packet[0] >> 4 == 4 else { + guard isIPv4Packet(packet) else { return } diff --git a/StikJIT/Utilities/DeviceTransport.swift b/StikJIT/Utilities/DeviceTransport.swift index 63a1730..8654baa 100644 --- a/StikJIT/Utilities/DeviceTransport.swift +++ b/StikJIT/Utilities/DeviceTransport.swift @@ -126,6 +126,9 @@ final class EmbeddedDeviceTransport: DeviceTransport { rp_pairing_file_read(path, &pairingFile) } if let pairingError { + if let pairingFile { + rp_pairing_file_free(pairingFile) + } throw DeviceTransportError.pairingFileReadFailed( consumeFFIError(pairingError, fallback: "unknown pairing-file error") ) diff --git a/StikJIT/Utilities/IdeviceFFIBridge.swift b/StikJIT/Utilities/IdeviceFFIBridge.swift index 0690705..39f3391 100644 --- a/StikJIT/Utilities/IdeviceFFIBridge.swift +++ b/StikJIT/Utilities/IdeviceFFIBridge.swift @@ -755,6 +755,11 @@ enum LocationSimulationCommandQueue { } func simulate_location(_ deviceIP: String, _ latitude: Double, _ longitude: Double, _ pairingFile: String) -> Int32 { + if LocationSimulationState.locationSimulation != nil + && !StikDebugVPNManager.shared.isTunnelReady() { + LocationSimulationState.cleanup() + } + if let locationSimulation = LocationSimulationState.locationSimulation { if let ffiError = location_simulation_set(locationSimulation, latitude, longitude) { idevice_error_free(ffiError) @@ -829,6 +834,11 @@ func simulate_location(_ deviceIP: String, _ latitude: Double, _ longitude: Doub } func clear_simulated_location() -> Int32 { + if !StikDebugVPNManager.shared.isTunnelReady() { + LocationSimulationState.cleanup() + return LocationSimulationStatus.locationClear + } + guard let locationSimulation = LocationSimulationState.locationSimulation else { return LocationSimulationStatus.locationClear } diff --git a/StikJIT/Utilities/JITEnableContext.swift b/StikJIT/Utilities/JITEnableContext.swift index afc02ff..47636d6 100644 --- a/StikJIT/Utilities/JITEnableContext.swift +++ b/StikJIT/Utilities/JITEnableContext.swift @@ -31,8 +31,15 @@ final class JITEnableContext { private var syslogLineHandler: SyslogLineHandler? private var syslogErrorHandler: SyslogErrorHandler? - var adapterHandle: OpaquePointer? { adapter } - var handshakeHandle: OpaquePointer? { handshake } + var adapterHandle: OpaquePointer? { + guard StikDebugVPNManager.shared.isTunnelReady() else { return nil } + return adapter + } + + var handshakeHandle: OpaquePointer? { + guard StikDebugVPNManager.shared.isTunnelReady() else { return nil } + return handshake + } private init() { let logURL = FileManager.default @@ -161,7 +168,7 @@ final class JITEnableContext { } func ensureTunnel() throws { - if adapter == nil || handshake == nil { + if adapter == nil || handshake == nil || !StikDebugVPNManager.shared.isTunnelReady() { try startTunnel() } } diff --git a/StikJIT/Utilities/StikDebugVPNManager.swift b/StikJIT/Utilities/StikDebugVPNManager.swift index 87d6081..425fa24 100644 --- a/StikJIT/Utilities/StikDebugVPNManager.swift +++ b/StikJIT/Utilities/StikDebugVPNManager.swift @@ -31,6 +31,10 @@ final class StikDebugVPNManager: ObservableObject { } func ensureReady() throws { + guard !Thread.isMainThread else { + throw StikDebugVPNError.mustRunOffMainThread + } + stateLock.lock() if let startingWaiter { stateLock.unlock() @@ -90,12 +94,6 @@ final class StikDebugVPNManager: ObservableObject { ) let managers = try loadManagers() - if managers.contains(where: { manager in - isActive(manager) && !isOwned(manager) - }) { - throw StikDebugVPNError.competingVPN - } - let selectedManager: NETunnelProviderManager if let existing = managers.first(where: isOwned) { selectedManager = existing @@ -106,13 +104,17 @@ final class StikDebugVPNManager: ObservableObject { let status = selectedManager.connection.status publish(StikDebugVPNStatus(neStatus: status)) - if status == .connected { + if status == .connected && isConfigured(selectedManager, with: configured) { stateLock.lock() manager = selectedManager stateLock.unlock() return } + if isActive(selectedManager) { + try stopAndWait(selectedManager) + } + configure(selectedManager, with: configured) selectedManager.isEnabled = true try save(selectedManager) @@ -152,16 +154,10 @@ final class StikDebugVPNManager: ObservableObject { manager.protocolConfiguration = protocolConfiguration manager.localizedDescription = "StikDebug Local Tunnel" - let onDemandRule = NEOnDemandRuleEvaluateConnection() - onDemandRule.interfaceTypeMatch = .any - onDemandRule.connectionRules = [ - NEEvaluateConnectionRule( - matchDomains: [configuration.peerIP], - andAction: .connectIfNeeded - ) - ] - manager.onDemandRules = [onDemandRule] - manager.isOnDemandEnabled = true + // RPPairing connects to a raw sockaddr, so automatic destination + // matching is not reliable. Start explicitly from the transport layer. + manager.onDemandRules = nil + manager.isOnDemandEnabled = false } private func isOwned(_ manager: NETunnelProviderManager) -> Bool { @@ -180,7 +176,40 @@ final class StikDebugVPNManager: ObservableObject { } } + private func isConfigured( + _ manager: NETunnelProviderManager, + with configuration: StikDebugTunnelConfiguration + ) -> Bool { + guard let providerConfiguration = + (manager.protocolConfiguration as? NETunnelProviderProtocol)?.providerConfiguration + else { + return false + } + + return providerConfiguration[StikDebugTunnelConfiguration.interfaceIPKey] as? String + == configuration.interfaceIP + && providerConfiguration[StikDebugTunnelConfiguration.peerIPKey] as? String + == configuration.peerIP + && !manager.isOnDemandEnabled + } + + private func stopAndWait(_ manager: NETunnelProviderManager) throws { + guard isActive(manager) else { return } + manager.connection.stopVPNTunnel() + _ = try waitForStatus(manager) { status in + status == .disconnected || status == .invalid + } + } + private func waitForConnection(_ manager: NETunnelProviderManager) throws { + let finalStatus = try waitForStatus(manager) { $0 == .connected } + publish(StikDebugVPNStatus(neStatus: finalStatus)) + } + + private func waitForStatus( + _ manager: NETunnelProviderManager, + matching predicate: @escaping (NEVPNStatus) -> Bool + ) throws -> NEVPNStatus { let semaphore = DispatchSemaphore(value: 0) let connection = manager.connection let observer = NotificationCenter.default.addObserver( @@ -190,15 +219,14 @@ final class StikDebugVPNManager: ObservableObject { ) { [weak self] _ in let status = connection.status self?.publish(StikDebugVPNStatus(neStatus: status)) - if status == .connected || status == .disconnected || status == .invalid { + if predicate(status) || status == .disconnected || status == .invalid { semaphore.signal() } } - if connection.status == .connected { + if predicate(connection.status) { NotificationCenter.default.removeObserver(observer) - publish(.connected) - return + return connection.status } DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 10) { @@ -208,13 +236,13 @@ final class StikDebugVPNManager: ObservableObject { NotificationCenter.default.removeObserver(observer) let finalStatus = connection.status - publish(StikDebugVPNStatus(neStatus: finalStatus)) - guard finalStatus == .connected else { + guard predicate(finalStatus) else { if finalStatus == .disconnected || finalStatus == .invalid { throw StikDebugVPNError.disconnected } throw StikDebugVPNError.timeout } + return finalStatus } private func loadManagers() throws -> [NETunnelProviderManager] { diff --git a/StikJIT/Utilities/StikDebugVPNStatus.swift b/StikJIT/Utilities/StikDebugVPNStatus.swift index 5d504ca..4c81e79 100644 --- a/StikJIT/Utilities/StikDebugVPNStatus.swift +++ b/StikJIT/Utilities/StikDebugVPNStatus.swift @@ -36,7 +36,7 @@ enum StikDebugVPNError: LocalizedError, Equatable { case profileLoadFailed(String) case profileSaveFailed(String) case permissionDenied(String) - case competingVPN + case mustRunOffMainThread case timeout case startFailed(String) case disconnected @@ -49,12 +49,12 @@ enum StikDebugVPNError: LocalizedError, Equatable { return "Unable to save the embedded VPN profile: \(message)" case .permissionDenied(let message): return "The embedded VPN permission was denied: \(message)" - case .competingVPN: - return "Another VPN is active. Stop it before starting the StikDebug local tunnel." + case .mustRunOffMainThread: + return "The embedded VPN must be started from a background queue." case .timeout: return "The embedded VPN did not become connected before the timeout." case .startFailed(let message): - return "The embedded VPN could not start: (message)" + return "The embedded VPN could not start: \(message)" case .disconnected: return "The embedded VPN disconnected before the device tunnel was ready." } diff --git a/StikJITTests/StikJITTests.swift b/StikJITTests/StikJITTests.swift index d3a980f..ea50f3e 100644 --- a/StikJITTests/StikJITTests.swift +++ b/StikJITTests/StikJITTests.swift @@ -31,6 +31,12 @@ struct StikJITTests { #expect(message.localizedCaseInsensitiveContains("vpn")) } + @Test func vpnStartErrorPreservesUnderlyingMessage() { + let message = StikDebugVPNError.startFailed("permission denied").errorDescription ?? "" + + #expect(message.localizedCaseInsensitiveContains("permission denied")) + } + @Test func vpnStatusMappingRecognizesConnectedAndReasserting() { #expect(StikDebugVPNStatus(neStatus: .connected) == .connected) #expect(StikDebugVPNStatus(neStatus: .reasserting) == .connecting) @@ -69,6 +75,12 @@ struct StikJITTests { #expect(Array(packet[16..<20]) == [10, 7, 0, 2]) } + @Test func ipv4PacketRewriterRecognizesOnlyCompleteIPv4Packets() { + #expect(IPv4PacketRewriter.isIPv4Packet([UInt8(0x45)] + [UInt8](repeating: 0, count: 19))) + #expect(!IPv4PacketRewriter.isIPv4Packet([UInt8(0x60)] + [UInt8](repeating: 0, count: 19))) + #expect(!IPv4PacketRewriter.isIPv4Packet([UInt8(0x45)] + [UInt8](repeating: 0, count: 18))) + } + @Test func ipv4PacketRewriterLeavesShortPacketsUnchanged() { var packet = [UInt8](repeating: 0, count: 19) let original = packet