From 3fa5d488c1af6f4bdddbc162d5ce8e78f2a58e1d Mon Sep 17 00:00:00 2001 From: zy860 Date: Tue, 18 Aug 2026 14:38:42 +0800 Subject: [PATCH] feat: manage embedded loopback vpn --- StikJIT/Utilities/StikDebugVPNManager.swift | 272 ++++++++++++++++++++ StikJIT/Utilities/StikDebugVPNStatus.swift | 62 +++++ StikJITTests/StikJITTests.swift | 16 ++ 3 files changed, 350 insertions(+) create mode 100644 StikJIT/Utilities/StikDebugVPNManager.swift create mode 100644 StikJIT/Utilities/StikDebugVPNStatus.swift diff --git a/StikJIT/Utilities/StikDebugVPNManager.swift b/StikJIT/Utilities/StikDebugVPNManager.swift new file mode 100644 index 0000000..4cd1c42 --- /dev/null +++ b/StikJIT/Utilities/StikDebugVPNManager.swift @@ -0,0 +1,272 @@ +// +// StikDebugVPNManager.swift +// StikDebug +// + +import Combine +import Foundation +import NetworkExtension + +final class StikDebugVPNManager: ObservableObject { + static let shared = StikDebugVPNManager() + static let providerBundleIdentifier = "com.stik.stikdebug.tunnel" + + @Published private(set) var currentStatus: StikDebugVPNStatus = .disconnected + + private let stateLock = NSLock() + private var manager: NETunnelProviderManager? + private var startingWaiter: DispatchSemaphore? + private var lastEnsureError: Error? + + private init() {} + + static func makeProviderConfiguration( + for configuration: StikDebugTunnelConfiguration + ) -> [String: String] { + configuration.providerConfiguration + } + + func start() throws { + try ensureReady() + } + + func ensureReady() throws { + stateLock.lock() + if let startingWaiter { + stateLock.unlock() + startingWaiter.wait() + stateLock.lock() + let error = lastEnsureError + stateLock.unlock() + if let error { throw error } + return + } + + let waiter = DispatchSemaphore(value: 0) + startingWaiter = waiter + lastEnsureError = nil + stateLock.unlock() + + var ensureError: Error? + defer { + stateLock.lock() + lastEnsureError = ensureError + startingWaiter = nil + stateLock.unlock() + waiter.signal() + } + + do { + try ensureReadyOnce() + } catch { + ensureError = error + throw error + } + } + + func stop() { + stateLock.lock() + let activeManager = manager + stateLock.unlock() + + activeManager?.connection.stopVPNTunnel() + publish(.disconnected) + } + + func isTunnelReady() -> Bool { + stateLock.lock() + let status = manager?.connection.status + stateLock.unlock() + return status == .connected + } + + private func ensureReadyOnce() throws { + let configured = StikDebugTunnelConfiguration( + interfaceIP: StikDebugTunnelConfiguration.default.interfaceIP, + peerIP: DeviceConnectionContext.targetIPAddress + ) + 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 + try reload(existing) + } else { + selectedManager = NETunnelProviderManager() + } + + let status = selectedManager.connection.status + publish(StikDebugVPNStatus(neStatus: status)) + if status == .connected { + stateLock.lock() + manager = selectedManager + stateLock.unlock() + return + } + + configure(selectedManager, with: configured) + selectedManager.isEnabled = true + try save(selectedManager) + try reload(selectedManager) + + stateLock.lock() + manager = selectedManager + stateLock.unlock() + + let options = configured.providerConfiguration.reduce(into: [String: NSObject]()) { result, item in + result[item.key] = item.value as NSString + } + + publish(.connecting) + do { + try selectedManager.connection.startVPNTunnel(options: options) + } catch { + throw StikDebugVPNError.startFailed(error.localizedDescription) + } + + try waitForConnection(selectedManager) + } + + private func configure( + _ manager: NETunnelProviderManager, + with configuration: StikDebugTunnelConfiguration + ) { + let protocolConfiguration = + (manager.protocolConfiguration as? NETunnelProviderProtocol) + ?? NETunnelProviderProtocol() + + protocolConfiguration.providerBundleIdentifier = Self.providerBundleIdentifier + protocolConfiguration.serverAddress = "StikDebug Local Tunnel" + protocolConfiguration.providerConfiguration = configuration.providerConfiguration.reduce(into: [String: Any]()) { + $0[$1.key] = $1.value + } + 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 + } + + private func isOwned(_ manager: NETunnelProviderManager) -> Bool { + guard let configuration = manager.protocolConfiguration as? NETunnelProviderProtocol else { + return false + } + return configuration.providerBundleIdentifier == Self.providerBundleIdentifier + } + + private func isActive(_ manager: NETunnelProviderManager) -> Bool { + switch manager.connection.status { + case .connected, .connecting, .reasserting: + return true + default: + return false + } + } + + private func waitForConnection(_ manager: NETunnelProviderManager) throws { + let semaphore = DispatchSemaphore(value: 0) + let connection = manager.connection + let observer = NotificationCenter.default.addObserver( + forName: .NEVPNStatusDidChange, + object: connection, + queue: nil + ) { [weak self] _ in + let status = connection.status + self?.publish(StikDebugVPNStatus(neStatus: status)) + if status == .connected || status == .disconnected || status == .invalid { + semaphore.signal() + } + } + + if connection.status == .connected { + NotificationCenter.default.removeObserver(observer) + publish(.connected) + return + } + + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 10) { + semaphore.signal() + } + semaphore.wait() + NotificationCenter.default.removeObserver(observer) + + let finalStatus = connection.status + publish(StikDebugVPNStatus(neStatus: finalStatus)) + guard finalStatus == .connected else { + if finalStatus == .disconnected || finalStatus == .invalid { + throw StikDebugVPNError.disconnected + } + throw StikDebugVPNError.timeout + } + } + + private func loadManagers() throws -> [NETunnelProviderManager] { + let semaphore = DispatchSemaphore(value: 0) + var loadedManagers: [NETunnelProviderManager] = [] + var loadError: Error? + + NETunnelProviderManager.loadAllFromPreferences { managers, error in + loadedManagers = managers ?? [] + loadError = error + semaphore.signal() + } + semaphore.wait() + + if let loadError { + throw StikDebugVPNError.profileLoadFailed(loadError.localizedDescription) + } + return loadedManagers + } + + private func reload(_ manager: NETunnelProviderManager) throws { + let semaphore = DispatchSemaphore(value: 0) + var reloadError: Error? + manager.loadFromPreferences { error in + reloadError = error + semaphore.signal() + } + semaphore.wait() + + if let reloadError { + throw StikDebugVPNError.profileLoadFailed(reloadError.localizedDescription) + } + } + + private func save(_ manager: NETunnelProviderManager) throws { + let semaphore = DispatchSemaphore(value: 0) + var saveError: Error? + manager.saveToPreferences { error in + saveError = error + semaphore.signal() + } + semaphore.wait() + + if let saveError { + throw StikDebugVPNError.profileSaveFailed(saveError.localizedDescription) + } + } + + private func publish(_ status: StikDebugVPNStatus) { + if Thread.isMainThread { + currentStatus = status + } else { + DispatchQueue.main.async { [weak self] in + self?.currentStatus = status + } + } + } +} diff --git a/StikJIT/Utilities/StikDebugVPNStatus.swift b/StikJIT/Utilities/StikDebugVPNStatus.swift new file mode 100644 index 0000000..d5a05b6 --- /dev/null +++ b/StikJIT/Utilities/StikDebugVPNStatus.swift @@ -0,0 +1,62 @@ +// +// StikDebugVPNStatus.swift +// StikDebug +// + +import NetworkExtension + +enum StikDebugVPNStatus: Equatable { + case disconnected + case connecting + case connected + case disconnecting + case failed + + init(neStatus: NEVPNStatus) { + switch neStatus { + case .invalid, .disconnected: + self = .disconnected + case .connecting, .reasserting: + self = .connecting + case .connected: + self = .connected + case .disconnecting: + self = .disconnecting + @unknown default: + self = .failed + } + } + + var isConnected: Bool { + self == .connected + } +} + +enum StikDebugVPNError: LocalizedError, Equatable { + case profileLoadFailed(String) + case profileSaveFailed(String) + case permissionDenied(String) + case competingVPN + case timeout + case startFailed(String) + case disconnected + + var errorDescription: String? { + switch self { + case .profileLoadFailed(let message): + return "Unable to load the embedded VPN profile: (message)" + case .profileSaveFailed(let message): + 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 .timeout: + return "The embedded VPN did not become connected before the timeout." + case .startFailed(let 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 4a3f514..5ac1b26 100644 --- a/StikJITTests/StikJITTests.swift +++ b/StikJITTests/StikJITTests.swift @@ -11,6 +11,22 @@ import Testing struct StikJITTests { + @Test func vpnStatusMappingRecognizesConnectedAndReasserting() { + #expect(StikDebugVPNStatus(neStatus: .connected) == .connected) + #expect(StikDebugVPNStatus(neStatus: .reasserting) == .connecting) + #expect(StikDebugVPNStatus(neStatus: .disconnected) == .disconnected) + } + + @Test func vpnManagerConfigurationUsesEmbeddedExtension() { + let configuration = StikDebugVPNManager.makeProviderConfiguration( + for: .default + ) + + #expect(configuration[StikDebugTunnelConfiguration.interfaceIPKey] == "10.7.0.0") + #expect(configuration[StikDebugTunnelConfiguration.peerIPKey] == "10.7.0.1") + #expect(StikDebugVPNManager.providerBundleIdentifier == "com.stik.stikdebug.tunnel") + } + @Test func embeddedTunnelDefaultsMatchLocalDevVPNEndpoint() { let configuration = StikDebugTunnelConfiguration.default