mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
feat: manage embedded loopback vpn
This commit is contained in:
parent
2272aa1331
commit
3fa5d488c1
3 changed files with 350 additions and 0 deletions
272
StikJIT/Utilities/StikDebugVPNManager.swift
Normal file
272
StikJIT/Utilities/StikDebugVPNManager.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
62
StikJIT/Utilities/StikDebugVPNStatus.swift
Normal file
62
StikJIT/Utilities/StikDebugVPNStatus.swift
Normal file
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue