feat: manage embedded loopback vpn

This commit is contained in:
zy860 2026-08-18 14:38:42 +08:00
parent 2272aa1331
commit 3fa5d488c1
3 changed files with 350 additions and 0 deletions

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

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

View file

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