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;
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>";
};

View file

@ -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()
}
}

View file

@ -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
}

View file

@ -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")
)

View file

@ -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
}

View file

@ -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()
}
}

View file

@ -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] {

View file

@ -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."
}

View file

@ -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