mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +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;
|
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>";
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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] {
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue