mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 12:02:14 +00:00
fix: harden embedded vpn lifecycle
This commit is contained in:
parent
aadf40aa30
commit
c93cc4b905
9 changed files with 122 additions and 48 deletions
|
|
@ -95,8 +95,18 @@
|
|||
path = StikJITUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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] {
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue