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.
This commit is contained in:
Conrad Kramer 2023-05-08 18:19:13 -04:00
parent eeb0130156
commit b3a540fc48
8 changed files with 277 additions and 28 deletions

View file

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

View file

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

View file

@ -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<Void, Error>) -> (Error?) -> Void {
return { error in
if let error = error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: ())
}
}
}

43
Apple/App/Status.swift Normal file
View file

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

135
Apple/App/Tunnel.swift Normal file
View file

@ -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<Void, Error>] = []
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
}
}
}

View file

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

View file

@ -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 = "<group>"; };
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 = "<group>"; };
D05B9F7729E39EEC008CB1F9 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
D05B9F7729E39EEC008CB1F9 /* TunnelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelView.swift; sourceTree = "<group>"; };
D05B9F7929E39EED008CB1F9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
D0B98FBF29FD8072004E7149 /* build-rust.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "build-rust.sh"; sourceTree = "<group>"; };
D0B98FC629FDC5B5004E7149 /* Tunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tunnel.swift; sourceTree = "<group>"; };
D0B98FD829FDDB6F004E7149 /* libburrow.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = libburrow.h; sourceTree = "<group>"; };
D0B98FDC29FDDDCF004E7149 /* module.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = module.modulemap; sourceTree = "<group>"; };
D0BCC5FC2A086D4700AD070D /* NetworkExtension+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetworkExtension+Async.swift"; sourceTree = "<group>"; };
D0BCC5FE2A086E1C00AD070D /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = "<group>"; };
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;
};

View file

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