From b3a540fc487c844681521ec9b272514d668fd639 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Mon, 8 May 2023 18:19:13 -0400 Subject: [PATCH] Add support for starting and stopping the tunnel This commit introduces the Tunnel view model object which has support for asking for permission, starting and stopping the tunnel. It automatically updates its state and publishes changes as an ObservableObject. --- Apple/App/BurrowApp.swift | 10 +- Apple/App/ContentView.swift | 19 --- Apple/App/NetworkExtension+Async.swift | 41 ++++++ Apple/App/Status.swift | 43 ++++++ Apple/App/Tunnel.swift | 135 ++++++++++++++++++ Apple/App/TunnelView.swift | 37 +++++ Apple/Burrow.xcodeproj/project.pbxproj | 14 +- .../PacketTunnelProvider.swift | 6 +- 8 files changed, 277 insertions(+), 28 deletions(-) delete mode 100644 Apple/App/ContentView.swift create mode 100644 Apple/App/NetworkExtension+Async.swift create mode 100644 Apple/App/Status.swift create mode 100644 Apple/App/Tunnel.swift create mode 100644 Apple/App/TunnelView.swift diff --git a/Apple/App/BurrowApp.swift b/Apple/App/BurrowApp.swift index b145dea..da0bf52 100644 --- a/Apple/App/BurrowApp.swift +++ b/Apple/App/BurrowApp.swift @@ -1,10 +1,18 @@ +import NetworkExtension import SwiftUI @main +@MainActor struct BurrowApp: App { + + static let tunnel = Tunnel { manager, proto in + proto.serverAddress = "hackclub.com" + manager.localizedDescription = "Burrow" + } + var body: some Scene { WindowGroup { - ContentView() + TunnelView(tunnel: Self.tunnel) } } } diff --git a/Apple/App/ContentView.swift b/Apple/App/ContentView.swift deleted file mode 100644 index f54deab..0000000 --- a/Apple/App/ContentView.swift +++ /dev/null @@ -1,19 +0,0 @@ -import SwiftUI - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundColor(.accentColor) - Text("Hello, world!") - } - .padding() - } -} - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView() - } -} diff --git a/Apple/App/NetworkExtension+Async.swift b/Apple/App/NetworkExtension+Async.swift new file mode 100644 index 0000000..ba478f3 --- /dev/null +++ b/Apple/App/NetworkExtension+Async.swift @@ -0,0 +1,41 @@ +import NetworkExtension + +extension NEVPNManager { + func remove() async throws { + let _: Void = try await withUnsafeThrowingContinuation { continuation in + removeFromPreferences(completionHandler: completion(continuation)) + } + } + + func save() async throws { + let _: Void = try await withUnsafeThrowingContinuation { continuation in + saveToPreferences(completionHandler: completion(continuation)) + } + } +} + +extension NETunnelProviderManager { + class var managers: [NETunnelProviderManager] { + get async throws { + try await withUnsafeThrowingContinuation { continuation in + loadAllFromPreferences { managers, error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: managers ?? []) + } + } + } + } + } +} + +private func completion(_ continuation: UnsafeContinuation) -> (Error?) -> Void { + return { error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + } +} diff --git a/Apple/App/Status.swift b/Apple/App/Status.swift new file mode 100644 index 0000000..d2ed65a --- /dev/null +++ b/Apple/App/Status.swift @@ -0,0 +1,43 @@ +import Foundation +import NetworkExtension + +extension Tunnel { + enum Status: CustomStringConvertible, Equatable, Hashable { + case unknown + case permissionRequired + case disabled + case connecting + case connected(Date) + case disconnecting + case disconnected + case reasserting + case invalid + case configurationReadWriteFailed + + var description: String { + switch self { + case .unknown: + return "Unknown" + case .permissionRequired: + return "Permission Required" + case .disconnected: + return "Disconnected" + case .disabled: + return "Disabled" + case .connecting: + return "Connecting" + case .connected(_): + return "Connected" + case .disconnecting: + return "Disconnecting" + case .reasserting: + return "Reasserting" + case .invalid: + return "Invalid" + case .configurationReadWriteFailed: + return "System Error" + } + } + } +} + diff --git a/Apple/App/Tunnel.swift b/Apple/App/Tunnel.swift new file mode 100644 index 0000000..64808d5 --- /dev/null +++ b/Apple/App/Tunnel.swift @@ -0,0 +1,135 @@ +import NetworkExtension +import SwiftUI +import Combine + +@MainActor +class Tunnel: ObservableObject { + @Published private(set) var status: Status = .unknown + @Published private var error: NEVPNError? + + private let bundleIdentifier: String + private let configure: (NETunnelProviderManager, NETunnelProviderProtocol) -> Void + private var tasks: [Task] = [] + + private var managers: [NEVPNManager]? { + didSet { status = currentStatus } + } + + private var currentStatus: Status { + guard let managers = managers else { + guard let error = error else { + return .unknown + } + + switch error.code { + case .configurationReadWriteFailed: + return .configurationReadWriteFailed + default: + return .unknown + } + } + + guard let manager = managers.first else { + return .permissionRequired + } + + guard manager.isEnabled else { + return .disabled + } + + return manager.connection.tunnelStatus + } + + convenience init(configure: @escaping (NETunnelProviderManager, NETunnelProviderProtocol) -> Void) { + self.init("com.hackclub.burrow.network", configure: configure) + } + + init(_ bundleIdentifier: String, configure: @escaping (NETunnelProviderManager, NETunnelProviderProtocol) -> Void) { + self.bundleIdentifier = bundleIdentifier + self.configure = configure + + let statusTask = Task { + for try await _ in NotificationCenter.default.notifications(named: .NEVPNStatusDidChange) { + status = currentStatus + } + } + let configurationTask = Task { + for try await _ in NotificationCenter.default.notifications(named: .NEVPNConfigurationChange) { + await update() + } + } + tasks = [statusTask, configurationTask] + } + + deinit { + tasks.forEach { $0.cancel() } + } + + func update() async { + do { + managers = try await NETunnelProviderManager.managers + } catch let error as NEVPNError { + self.error = error + } catch { + print(error) + } + } + + func configure() async throws { + if managers == nil { + await update() + } + + guard let managers = managers else { return } + + if managers.count > 1 { + try await withThrowingTaskGroup(of: Void.self, returning: Void.self) { group in + for manager in managers.suffix(from: 1) { + group.addTask { try await manager.remove() } + } + try await group.waitForAll() + } + } + + if managers.isEmpty { + let manager = NETunnelProviderManager() + let proto = NETunnelProviderProtocol() + proto.providerBundleIdentifier = bundleIdentifier + manager.protocolConfiguration = proto + + configure(manager, proto) + try await manager.save() + } + } + + func start() throws { + guard let manager = managers?.first else { return } + try manager.connection.startVPNTunnel() + } + + func stop() { + guard let manager = managers?.first else { return } + manager.connection.stopVPNTunnel() + } +} + +private extension NEVPNConnection { + var tunnelStatus: Tunnel.Status { + switch status { + case .connected: + return .connected(connectedDate!) + case .connecting: + return .connecting + case .disconnecting: + return .disconnecting + case .disconnected: + return .disconnected + case .reasserting: + return .reasserting + case .invalid: + return .invalid + @unknown default: + return .unknown + } + } +} diff --git a/Apple/App/TunnelView.swift b/Apple/App/TunnelView.swift new file mode 100644 index 0000000..58153d4 --- /dev/null +++ b/Apple/App/TunnelView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct TunnelView: View { + + @ObservedObject + var tunnel: Tunnel + + var body: some View { + VStack { + Text(verbatim: tunnel.status.description) + switch tunnel.status { + case .connected(_): + Button("Disconnect", action: stop) + case .permissionRequired: + Button("Allow", action: configure) + case .disconnected: + Button("Start", action: start) + default: + EmptyView() + } + } + .task { await tunnel.update() } + .padding() + } + + private func start() { + try? tunnel.start() + } + + private func stop() { + tunnel.stop() + } + + private func configure() { + Task { try await tunnel.configure() } + } +} diff --git a/Apple/Burrow.xcodeproj/project.pbxproj b/Apple/Burrow.xcodeproj/project.pbxproj index e69eb6c..f65dc39 100644 --- a/Apple/Burrow.xcodeproj/project.pbxproj +++ b/Apple/Burrow.xcodeproj/project.pbxproj @@ -10,8 +10,11 @@ D020F65829E4A697002790F6 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F65729E4A697002790F6 /* PacketTunnelProvider.swift */; }; D020F65D29E4A697002790F6 /* BurrowNetworkExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D020F65329E4A697002790F6 /* BurrowNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D05B9F7629E39EEC008CB1F9 /* BurrowApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05B9F7529E39EEC008CB1F9 /* BurrowApp.swift */; }; - D05B9F7829E39EEC008CB1F9 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05B9F7729E39EEC008CB1F9 /* ContentView.swift */; }; + D05B9F7829E39EEC008CB1F9 /* TunnelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05B9F7729E39EEC008CB1F9 /* TunnelView.swift */; }; D05B9F7A29E39EED008CB1F9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D05B9F7929E39EED008CB1F9 /* Assets.xcassets */; }; + D0BCC5FD2A086D4700AD070D /* NetworkExtension+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BCC5FC2A086D4700AD070D /* NetworkExtension+Async.swift */; }; + D0BCC5FF2A086E1C00AD070D /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BCC5FE2A086E1C00AD070D /* Status.swift */; }; + D0BCC6082A0981FE00AD070D /* Tunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B98FC629FDC5B5004E7149 /* Tunnel.swift */; }; D0BCC6092A09A03E00AD070D /* libburrow.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D0BCC6032A09535900AD070D /* libburrow.a */; }; D0BCC60A2A09A0B800AD070D /* build-rust.sh in Resources */ = {isa = PBXBuildFile; fileRef = D0B98FBF29FD8072004E7149 /* build-rust.sh */; }; /* End PBXBuildFile section */ @@ -57,11 +60,13 @@ D020F66929E4AA74002790F6 /* App-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "App-macOS.entitlements"; sourceTree = ""; }; D05B9F7229E39EEC008CB1F9 /* Burrow.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Burrow.app; sourceTree = BUILT_PRODUCTS_DIR; }; D05B9F7529E39EEC008CB1F9 /* BurrowApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurrowApp.swift; sourceTree = ""; }; - D05B9F7729E39EEC008CB1F9 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + D05B9F7729E39EEC008CB1F9 /* TunnelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelView.swift; sourceTree = ""; }; D05B9F7929E39EED008CB1F9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; D0B98FBF29FD8072004E7149 /* build-rust.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "build-rust.sh"; sourceTree = ""; }; + D0B98FC629FDC5B5004E7149 /* Tunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tunnel.swift; sourceTree = ""; }; D0B98FD829FDDB6F004E7149 /* libburrow.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = libburrow.h; sourceTree = ""; }; D0B98FDC29FDDDCF004E7149 /* module.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = module.modulemap; sourceTree = ""; }; + D0BCC5FC2A086D4700AD070D /* NetworkExtension+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetworkExtension+Async.swift"; sourceTree = ""; }; D0BCC5FE2A086E1C00AD070D /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; D0BCC6032A09535900AD070D /* libburrow.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libburrow.a; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -288,8 +293,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D05B9F7829E39EEC008CB1F9 /* ContentView.swift in Sources */, + D0BCC6082A0981FE00AD070D /* Tunnel.swift in Sources */, + D05B9F7829E39EEC008CB1F9 /* TunnelView.swift in Sources */, + D0BCC5FF2A086E1C00AD070D /* Status.swift in Sources */, D05B9F7629E39EEC008CB1F9 /* BurrowApp.swift in Sources */, + D0BCC5FD2A086D4700AD070D /* NetworkExtension+Async.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Apple/NetworkExtension/PacketTunnelProvider.swift b/Apple/NetworkExtension/PacketTunnelProvider.swift index c8a87cf..1ac4a3b 100644 --- a/Apple/NetworkExtension/PacketTunnelProvider.swift +++ b/Apple/NetworkExtension/PacketTunnelProvider.swift @@ -3,27 +3,23 @@ import NetworkExtension class PacketTunnelProvider: NEPacketTunnelProvider { override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) { - // Add code here to start the process of connecting the tunnel. + completionHandler(nil) } override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { - // Add code here to start the process of stopping the tunnel. completionHandler() } override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) { - // Add code here to handle the message. if let handler = completionHandler { handler(messageData) } } override func sleep(completionHandler: @escaping () -> Void) { - // Add code here to get ready to sleep. completionHandler() } override func wake() { - // Add code here to wake up. } }