fix: harden embedded vpn lifecycle

This commit is contained in:
zy860 2026-08-18 15:06:10 +08:00
parent aadf40aa30
commit c93cc4b905
9 changed files with 122 additions and 48 deletions

View file

@ -95,8 +95,18 @@
path = StikJITUITests; path = StikJITUITests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A4B2C3D40123456789ABC002 /* Exceptions for "StikDebugTunnel" folder in "StikDebugTunnel" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = A5B2C3D40123456789ABC001 /* StikDebugTunnel */;
};
A4B2C3D40123456789ABC001 /* StikDebugTunnel */ = { A4B2C3D40123456789ABC001 /* StikDebugTunnel */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
A4B2C3D40123456789ABC002 /* Exceptions for "StikDebugTunnel" folder in "StikDebugTunnel" target */,
);
path = StikDebugTunnel; path = StikDebugTunnel;
sourceTree = "<group>"; sourceTree = "<group>";
}; };

View file

@ -4,6 +4,7 @@
// //
import Darwin import Darwin
import Foundation
import NetworkExtension import NetworkExtension
final class PacketTunnelProvider: NEPacketTunnelProvider { final class PacketTunnelProvider: NEPacketTunnelProvider {
@ -73,25 +74,24 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
packetFlow.readPackets { [weak self] packets, protocols in packetFlow.readPackets { [weak self] packets, protocols in
guard let self, self.packetLoopActive else { return } guard let self, self.packetLoopActive else { return }
var rewrittenPackets = packets var rewrittenPackets: [Data] = []
for index in rewrittenPackets.indices { var rewrittenProtocols: [NSNumber] = []
guard protocols[index].int32Value == AF_INET else { continue } for (packet, proto) in zip(packets, protocols) {
rewrittenPackets[index].withUnsafeMutableBytes { rawBuffer in guard proto.int32Value == AF_INET else { continue }
guard let baseAddress = rawBuffer.baseAddress else { return }
var packet = Array( var bytes = [UInt8](packet)
UnsafeRawBufferPointer( guard IPv4PacketRewriter.isIPv4Packet(bytes) else { continue }
start: baseAddress, IPv4PacketRewriter.swapEndpoints(in: &bytes)
count: rawBuffer.count rewrittenPackets.append(Data(bytes))
) rewrittenProtocols.append(proto)
)
IPv4PacketRewriter.swapEndpoints(in: &packet)
packet.withUnsafeBytes { rewrittenBuffer in
rawBuffer.copyBytes(from: rewrittenBuffer)
}
}
} }
self.packetFlow.writePackets(rewrittenPackets, withProtocols: protocols) if !rewrittenPackets.isEmpty {
self.packetFlow.writePackets(
rewrittenPackets,
withProtocols: rewrittenProtocols
)
}
self.readPackets() self.readPackets()
} }
} }

View file

@ -4,8 +4,12 @@
// //
enum IPv4PacketRewriter { enum IPv4PacketRewriter {
static func isIPv4Packet(_ packet: [UInt8]) -> Bool {
packet.count >= 20 && packet[0] >> 4 == 4
}
static func swapEndpoints(in packet: inout [UInt8]) { static func swapEndpoints(in packet: inout [UInt8]) {
guard packet.count >= 20, packet[0] >> 4 == 4 else { guard isIPv4Packet(packet) else {
return return
} }

View file

@ -126,6 +126,9 @@ final class EmbeddedDeviceTransport: DeviceTransport {
rp_pairing_file_read(path, &pairingFile) rp_pairing_file_read(path, &pairingFile)
} }
if let pairingError { if let pairingError {
if let pairingFile {
rp_pairing_file_free(pairingFile)
}
throw DeviceTransportError.pairingFileReadFailed( throw DeviceTransportError.pairingFileReadFailed(
consumeFFIError(pairingError, fallback: "unknown pairing-file error") consumeFFIError(pairingError, fallback: "unknown pairing-file error")
) )

View file

@ -755,6 +755,11 @@ enum LocationSimulationCommandQueue {
} }
func simulate_location(_ deviceIP: String, _ latitude: Double, _ longitude: Double, _ pairingFile: String) -> Int32 { 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 locationSimulation = LocationSimulationState.locationSimulation {
if let ffiError = location_simulation_set(locationSimulation, latitude, longitude) { if let ffiError = location_simulation_set(locationSimulation, latitude, longitude) {
idevice_error_free(ffiError) idevice_error_free(ffiError)
@ -829,6 +834,11 @@ func simulate_location(_ deviceIP: String, _ latitude: Double, _ longitude: Doub
} }
func clear_simulated_location() -> Int32 { func clear_simulated_location() -> Int32 {
if !StikDebugVPNManager.shared.isTunnelReady() {
LocationSimulationState.cleanup()
return LocationSimulationStatus.locationClear
}
guard let locationSimulation = LocationSimulationState.locationSimulation else { guard let locationSimulation = LocationSimulationState.locationSimulation else {
return LocationSimulationStatus.locationClear return LocationSimulationStatus.locationClear
} }

View file

@ -31,8 +31,15 @@ final class JITEnableContext {
private var syslogLineHandler: SyslogLineHandler? private var syslogLineHandler: SyslogLineHandler?
private var syslogErrorHandler: SyslogErrorHandler? private var syslogErrorHandler: SyslogErrorHandler?
var adapterHandle: OpaquePointer? { adapter } var adapterHandle: OpaquePointer? {
var handshakeHandle: OpaquePointer? { handshake } guard StikDebugVPNManager.shared.isTunnelReady() else { return nil }
return adapter
}
var handshakeHandle: OpaquePointer? {
guard StikDebugVPNManager.shared.isTunnelReady() else { return nil }
return handshake
}
private init() { private init() {
let logURL = FileManager.default let logURL = FileManager.default
@ -161,7 +168,7 @@ final class JITEnableContext {
} }
func ensureTunnel() throws { func ensureTunnel() throws {
if adapter == nil || handshake == nil { if adapter == nil || handshake == nil || !StikDebugVPNManager.shared.isTunnelReady() {
try startTunnel() try startTunnel()
} }
} }

View file

@ -31,6 +31,10 @@ final class StikDebugVPNManager: ObservableObject {
} }
func ensureReady() throws { func ensureReady() throws {
guard !Thread.isMainThread else {
throw StikDebugVPNError.mustRunOffMainThread
}
stateLock.lock() stateLock.lock()
if let startingWaiter { if let startingWaiter {
stateLock.unlock() stateLock.unlock()
@ -90,12 +94,6 @@ final class StikDebugVPNManager: ObservableObject {
) )
let managers = try loadManagers() let managers = try loadManagers()
if managers.contains(where: { manager in
isActive(manager) && !isOwned(manager)
}) {
throw StikDebugVPNError.competingVPN
}
let selectedManager: NETunnelProviderManager let selectedManager: NETunnelProviderManager
if let existing = managers.first(where: isOwned) { if let existing = managers.first(where: isOwned) {
selectedManager = existing selectedManager = existing
@ -106,13 +104,17 @@ final class StikDebugVPNManager: ObservableObject {
let status = selectedManager.connection.status let status = selectedManager.connection.status
publish(StikDebugVPNStatus(neStatus: status)) publish(StikDebugVPNStatus(neStatus: status))
if status == .connected { if status == .connected && isConfigured(selectedManager, with: configured) {
stateLock.lock() stateLock.lock()
manager = selectedManager manager = selectedManager
stateLock.unlock() stateLock.unlock()
return return
} }
if isActive(selectedManager) {
try stopAndWait(selectedManager)
}
configure(selectedManager, with: configured) configure(selectedManager, with: configured)
selectedManager.isEnabled = true selectedManager.isEnabled = true
try save(selectedManager) try save(selectedManager)
@ -152,16 +154,10 @@ final class StikDebugVPNManager: ObservableObject {
manager.protocolConfiguration = protocolConfiguration manager.protocolConfiguration = protocolConfiguration
manager.localizedDescription = "StikDebug Local Tunnel" manager.localizedDescription = "StikDebug Local Tunnel"
let onDemandRule = NEOnDemandRuleEvaluateConnection() // RPPairing connects to a raw sockaddr, so automatic destination
onDemandRule.interfaceTypeMatch = .any // matching is not reliable. Start explicitly from the transport layer.
onDemandRule.connectionRules = [ manager.onDemandRules = nil
NEEvaluateConnectionRule( manager.isOnDemandEnabled = false
matchDomains: [configuration.peerIP],
andAction: .connectIfNeeded
)
]
manager.onDemandRules = [onDemandRule]
manager.isOnDemandEnabled = true
} }
private func isOwned(_ manager: NETunnelProviderManager) -> Bool { 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 { 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 semaphore = DispatchSemaphore(value: 0)
let connection = manager.connection let connection = manager.connection
let observer = NotificationCenter.default.addObserver( let observer = NotificationCenter.default.addObserver(
@ -190,15 +219,14 @@ final class StikDebugVPNManager: ObservableObject {
) { [weak self] _ in ) { [weak self] _ in
let status = connection.status let status = connection.status
self?.publish(StikDebugVPNStatus(neStatus: status)) self?.publish(StikDebugVPNStatus(neStatus: status))
if status == .connected || status == .disconnected || status == .invalid { if predicate(status) || status == .disconnected || status == .invalid {
semaphore.signal() semaphore.signal()
} }
} }
if connection.status == .connected { if predicate(connection.status) {
NotificationCenter.default.removeObserver(observer) NotificationCenter.default.removeObserver(observer)
publish(.connected) return connection.status
return
} }
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 10) { DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 10) {
@ -208,13 +236,13 @@ final class StikDebugVPNManager: ObservableObject {
NotificationCenter.default.removeObserver(observer) NotificationCenter.default.removeObserver(observer)
let finalStatus = connection.status let finalStatus = connection.status
publish(StikDebugVPNStatus(neStatus: finalStatus)) guard predicate(finalStatus) else {
guard finalStatus == .connected else {
if finalStatus == .disconnected || finalStatus == .invalid { if finalStatus == .disconnected || finalStatus == .invalid {
throw StikDebugVPNError.disconnected throw StikDebugVPNError.disconnected
} }
throw StikDebugVPNError.timeout throw StikDebugVPNError.timeout
} }
return finalStatus
} }
private func loadManagers() throws -> [NETunnelProviderManager] { private func loadManagers() throws -> [NETunnelProviderManager] {

View file

@ -36,7 +36,7 @@ enum StikDebugVPNError: LocalizedError, Equatable {
case profileLoadFailed(String) case profileLoadFailed(String)
case profileSaveFailed(String) case profileSaveFailed(String)
case permissionDenied(String) case permissionDenied(String)
case competingVPN case mustRunOffMainThread
case timeout case timeout
case startFailed(String) case startFailed(String)
case disconnected case disconnected
@ -49,12 +49,12 @@ enum StikDebugVPNError: LocalizedError, Equatable {
return "Unable to save the embedded VPN profile: \(message)" return "Unable to save the embedded VPN profile: \(message)"
case .permissionDenied(let message): case .permissionDenied(let message):
return "The embedded VPN permission was denied: \(message)" return "The embedded VPN permission was denied: \(message)"
case .competingVPN: case .mustRunOffMainThread:
return "Another VPN is active. Stop it before starting the StikDebug local tunnel." return "The embedded VPN must be started from a background queue."
case .timeout: case .timeout:
return "The embedded VPN did not become connected before the timeout." return "The embedded VPN did not become connected before the timeout."
case .startFailed(let message): case .startFailed(let message):
return "The embedded VPN could not start: (message)" return "The embedded VPN could not start: \(message)"
case .disconnected: case .disconnected:
return "The embedded VPN disconnected before the device tunnel was ready." return "The embedded VPN disconnected before the device tunnel was ready."
} }

View file

@ -31,6 +31,12 @@ struct StikJITTests {
#expect(message.localizedCaseInsensitiveContains("vpn")) #expect(message.localizedCaseInsensitiveContains("vpn"))
} }
@Test func vpnStartErrorPreservesUnderlyingMessage() {
let message = StikDebugVPNError.startFailed("permission denied").errorDescription ?? ""
#expect(message.localizedCaseInsensitiveContains("permission denied"))
}
@Test func vpnStatusMappingRecognizesConnectedAndReasserting() { @Test func vpnStatusMappingRecognizesConnectedAndReasserting() {
#expect(StikDebugVPNStatus(neStatus: .connected) == .connected) #expect(StikDebugVPNStatus(neStatus: .connected) == .connected)
#expect(StikDebugVPNStatus(neStatus: .reasserting) == .connecting) #expect(StikDebugVPNStatus(neStatus: .reasserting) == .connecting)
@ -69,6 +75,12 @@ struct StikJITTests {
#expect(Array(packet[16..<20]) == [10, 7, 0, 2]) #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() { @Test func ipv4PacketRewriterLeavesShortPacketsUnchanged() {
var packet = [UInt8](repeating: 0, count: 19) var packet = [UInt8](repeating: 0, count: 19)
let original = packet let original = packet