diff --git a/Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj b/Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj
index 085ddb4..9ecb95e 100644
--- a/Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj
+++ b/Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj
@@ -769,6 +769,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Coder Desktop/Coder_Desktop.entitlements";
+ CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
@@ -788,6 +789,7 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.coder.Coder-Desktop";
PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
};
@@ -799,6 +801,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Coder Desktop/Coder_Desktop.entitlements";
+ CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
@@ -818,6 +821,7 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.coder.Coder-Desktop";
PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
};
@@ -901,6 +905,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = VPN/VPN.entitlements;
+ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
@@ -932,6 +937,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = VPN/VPN.entitlements;
+ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
diff --git a/Coder Desktop/Coder Desktop/Coder_Desktop.entitlements b/Coder Desktop/Coder Desktop/Coder_Desktop.entitlements
index 441279c..91f1361 100644
--- a/Coder Desktop/Coder Desktop/Coder_Desktop.entitlements
+++ b/Coder Desktop/Coder Desktop/Coder_Desktop.entitlements
@@ -6,6 +6,8 @@
packet-tunnel-provider
+ com.apple.developer.system-extension.install
+
com.apple.security.app-sandbox
com.apple.security.files.user-selected.read-only
diff --git a/Coder Desktop/Coder Desktop/NetworkExtension.swift b/Coder Desktop/Coder Desktop/NetworkExtension.swift
new file mode 100644
index 0000000..4c29256
--- /dev/null
+++ b/Coder Desktop/Coder Desktop/NetworkExtension.swift
@@ -0,0 +1,113 @@
+import NetworkExtension
+import os
+
+enum NetworkExtensionState: Equatable {
+ case unconfigured
+ case disbled
+ case enabled
+ case failed(String)
+
+ var description: String {
+ switch self {
+ case .unconfigured:
+ return "Not logged in to Coder"
+ case .enabled:
+ return "NetworkExtension tunnel enabled"
+ case .disbled:
+ return "NetworkExtension tunnel disabled"
+ case let .failed(error):
+ return "NetworkExtension config failed: \(error)"
+ }
+ }
+}
+
+/// An actor that handles configuring, enabling, and disabling the VPN tunnel via the
+/// NetworkExtension APIs.
+extension CoderVPNService {
+ func configureNetworkExtension(proto: NETunnelProviderProtocol) async {
+ // removing the old tunnels, rather than reconfiguring ensures that configuration changes
+ // are picked up.
+ do {
+ try await removeNetworkExtension()
+ } catch {
+ logger.error("remove tunnel failed: \(error)")
+ neState = .failed(error.localizedDescription)
+ return
+ }
+ logger.debug("inserting new tunnel")
+
+ let tm = NETunnelProviderManager()
+ tm.localizedDescription = "CoderVPN"
+ tm.protocolConfiguration = proto
+
+ logger.debug("saving new tunnel")
+ do {
+ try await tm.saveToPreferences()
+ } catch {
+ logger.error("save tunnel failed: \(error)")
+ neState = .failed(error.localizedDescription)
+ }
+ }
+
+ func removeNetworkExtension() async throws(VPNServiceError) {
+ do {
+ let tunnels = try await NETunnelProviderManager.loadAllFromPreferences()
+ for tunnel in tunnels {
+ try await tunnel.removeFromPreferences()
+ }
+ } catch {
+ throw .internalError("couldn't remove tunnels: \(error)")
+ }
+ }
+
+ func enableNetworkExtension() async {
+ do {
+ let tm = try await getTunnelManager()
+ if !tm.isEnabled {
+ tm.isEnabled = true
+ try await tm.saveToPreferences()
+ logger.debug("saved tunnel with enabled=true")
+ }
+ try tm.connection.startVPNTunnel()
+ } catch {
+ logger.error("enable network extension: \(error)")
+ neState = .failed(error.localizedDescription)
+ return
+ }
+ logger.debug("enabled and started tunnel")
+ neState = .enabled
+ }
+
+ func disableNetworkExtension() async {
+ do {
+ let tm = try await getTunnelManager()
+ tm.connection.stopVPNTunnel()
+ tm.isEnabled = false
+
+ try await tm.saveToPreferences()
+ } catch {
+ logger.error("disable network extension: \(error)")
+ neState = .failed(error.localizedDescription)
+ return
+ }
+ logger.debug("saved tunnel with enabled=false")
+ neState = .disbled
+ }
+
+ private func getTunnelManager() async throws(VPNServiceError) -> NETunnelProviderManager {
+ var tunnels: [NETunnelProviderManager] = []
+ do {
+ tunnels = try await NETunnelProviderManager.loadAllFromPreferences()
+ } catch {
+ throw .internalError("couldn't load tunnels: \(error)")
+ }
+ if tunnels.isEmpty {
+ throw .internalError("no tunnels found")
+ }
+ return tunnels.first!
+ }
+}
+
+// we're going to mark NETunnelProviderManager as Sendable since there are official APIs that return
+// it async.
+extension NETunnelProviderManager: @unchecked @retroactive Sendable {}
diff --git a/Coder Desktop/Coder Desktop/Preview Content/PreviewSession.swift b/Coder Desktop/Coder Desktop/Preview Content/PreviewSession.swift
index c4022ff..a065194 100644
--- a/Coder Desktop/Coder Desktop/Preview Content/PreviewSession.swift
+++ b/Coder Desktop/Coder Desktop/Preview Content/PreviewSession.swift
@@ -1,3 +1,4 @@
+import NetworkExtension
import SwiftUI
class PreviewSession: Session {
@@ -21,4 +22,8 @@ class PreviewSession: Session {
hasSession = false
sessionToken = nil
}
+
+ func tunnelProviderProtocol() -> NETunnelProviderProtocol? {
+ return nil
+ }
}
diff --git a/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift b/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift
index 0e08bf9..04f37c0 100644
--- a/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift
+++ b/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift
@@ -1,3 +1,4 @@
+import NetworkExtension
import SwiftUI
@MainActor
@@ -28,10 +29,10 @@ final class PreviewVPN: Coder_Desktop.VPNService {
do {
try await Task.sleep(for: .seconds(10))
} catch {
- state = .failed(.exampleError)
+ state = .failed(.longTestError)
return
}
- state = shouldFail ? .failed(.exampleError) : .connected
+ state = shouldFail ? .failed(.longTestError) : .connected
}
func stop() async {
@@ -40,9 +41,13 @@ final class PreviewVPN: Coder_Desktop.VPNService {
do {
try await Task.sleep(for: .seconds(10))
} catch {
- state = .failed(.exampleError)
+ state = .failed(.longTestError)
return
}
state = .disabled
}
+
+ func configureTunnelProviderProtocol(proto _: NETunnelProviderProtocol?) {
+ state = .connecting
+ }
}
diff --git a/Coder Desktop/Coder Desktop/Session.swift b/Coder Desktop/Coder Desktop/Session.swift
index ec99d3e..2e39ada 100644
--- a/Coder Desktop/Coder Desktop/Session.swift
+++ b/Coder Desktop/Coder Desktop/Session.swift
@@ -1,5 +1,6 @@
import Foundation
import KeychainAccess
+import NetworkExtension
protocol Session: ObservableObject {
var hasSession: Bool { get }
@@ -8,9 +9,12 @@ protocol Session: ObservableObject {
func store(baseAccessURL: URL, sessionToken: String)
func clear()
+ func tunnelProviderProtocol() -> NETunnelProviderProtocol?
}
-class SecureSession: ObservableObject {
+class SecureSession: ObservableObject, Session {
+ let appId = Bundle.main.bundleIdentifier!
+
// Stored in UserDefaults
@Published private(set) var hasSession: Bool {
didSet {
@@ -31,9 +35,21 @@ class SecureSession: ObservableObject {
}
}
+ func tunnelProviderProtocol() -> NETunnelProviderProtocol? {
+ if !hasSession { return nil }
+ let proto = NETunnelProviderProtocol()
+ proto.providerBundleIdentifier = "\(appId).VPN"
+ proto.passwordReference = keychain[attributes: Keys.sessionToken]?.persistentRef
+ proto.serverAddress = baseAccessURL!.absoluteString
+ return proto
+ }
+
private let keychain: Keychain
- public init() {
+ let onChange: ((NETunnelProviderProtocol?) -> Void)?
+
+ public init(onChange: ((NETunnelProviderProtocol?) -> Void)? = nil) {
+ self.onChange = onChange
keychain = Keychain(service: Bundle.main.bundleIdentifier!)
_hasSession = Published(initialValue: UserDefaults.standard.bool(forKey: Keys.hasSession))
_baseAccessURL = Published(initialValue: UserDefaults.standard.url(forKey: Keys.baseAccessURL))
@@ -46,11 +62,13 @@ class SecureSession: ObservableObject {
hasSession = true
self.baseAccessURL = baseAccessURL
self.sessionToken = sessionToken
+ if let onChange { onChange(tunnelProviderProtocol()) }
}
public func clear() {
hasSession = false
sessionToken = nil
+ if let onChange { onChange(tunnelProviderProtocol()) }
}
private func keychainGet(for key: String) -> String? {
diff --git a/Coder Desktop/Coder Desktop/SystemExtension.swift b/Coder Desktop/Coder Desktop/SystemExtension.swift
new file mode 100644
index 0000000..0bddbac
--- /dev/null
+++ b/Coder Desktop/Coder Desktop/SystemExtension.swift
@@ -0,0 +1,135 @@
+import Foundation
+import os
+import SystemExtensions
+
+enum SystemExtensionState: Equatable, Sendable {
+ case uninstalled
+ case needsUserApproval
+ case installed
+ case failed(String)
+
+ var description: String {
+ switch self {
+ case .uninstalled:
+ return "VPN SystemExtension is waiting to be activated"
+ case .needsUserApproval:
+ return "VPN SystemExtension needs user approval to activate"
+ case .installed:
+ return "VPN SystemExtension is installed"
+ case let .failed(error):
+ return "VPN SystemExtension failed with error: \(error)"
+ }
+ }
+}
+
+protocol SystemExtensionAsyncRecorder: Sendable {
+ func recordSystemExtensionState(_ state: SystemExtensionState) async
+}
+
+extension CoderVPNService: SystemExtensionAsyncRecorder {
+ func recordSystemExtensionState(_ state: SystemExtensionState) async {
+ sysExtnState = state
+ }
+
+ var extensionBundle: Bundle {
+ let extensionsDirectoryURL = URL(
+ fileURLWithPath: "Contents/Library/SystemExtensions",
+ relativeTo: Bundle.main.bundleURL
+ )
+ let extensionURLs: [URL]
+ do {
+ extensionURLs = try FileManager.default.contentsOfDirectory(at: extensionsDirectoryURL,
+ includingPropertiesForKeys: nil,
+ options: .skipsHiddenFiles)
+ } catch {
+ fatalError("Failed to get the contents of " +
+ "\(extensionsDirectoryURL.absoluteString): \(error.localizedDescription)")
+ }
+
+ // here we're just going to assume that there is only ever going to be one SystemExtension
+ // packaged up in the application bundle. If we ever need to ship multiple versions or have
+ // multiple extensions, we'll need to revisit this assumption.
+ guard let extensionURL = extensionURLs.first else {
+ fatalError("Failed to find any system extensions")
+ }
+
+ guard let extensionBundle = Bundle(url: extensionURL) else {
+ fatalError("Failed to create a bundle with URL \(extensionURL.absoluteString)")
+ }
+
+ return extensionBundle
+ }
+
+ func installSystemExtension() {
+ logger.info("activating SystemExtension")
+ guard let bundleID = extensionBundle.bundleIdentifier else {
+ logger.error("Bundle has no identifier")
+ return
+ }
+ let request = OSSystemExtensionRequest.activationRequest(
+ forExtensionWithIdentifier: bundleID,
+ queue: .main
+ )
+ let delegate = SystemExtensionDelegate(asyncDelegate: self)
+ request.delegate = delegate
+ OSSystemExtensionManager.shared.submitRequest(request)
+ logger.info("submitted SystemExtension request with bundleID: \(bundleID)")
+ }
+}
+
+/// A delegate for the OSSystemExtensionRequest that maps the callbacks to async calls on the
+/// AsyncDelegate (CoderVPNService in production).
+class SystemExtensionDelegate:
+ NSObject, OSSystemExtensionRequestDelegate
+{
+ private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "vpn-installer")
+ private var asyncDelegate: AsyncDelegate
+
+ init(asyncDelegate: AsyncDelegate) {
+ self.asyncDelegate = asyncDelegate
+ logger.info("SystemExtensionDelegate initialized")
+ }
+
+ func request(
+ _: OSSystemExtensionRequest,
+ didFinishWithResult result: OSSystemExtensionRequest.Result
+ ) {
+ guard result == .completed else {
+ logger.error("Unexpected result \(result.rawValue) for system extension request")
+ let state = SystemExtensionState.failed("system extension not installed: \(result.rawValue)")
+ Task { [asyncDelegate] in
+ await asyncDelegate.recordSystemExtensionState(state)
+ }
+ return
+ }
+ logger.info("SystemExtension activated")
+ Task { [asyncDelegate] in
+ await asyncDelegate.recordSystemExtensionState(SystemExtensionState.installed)
+ }
+ }
+
+ func request(_: OSSystemExtensionRequest, didFailWithError error: Error) {
+ logger.error("System extension request failed: \(error.localizedDescription)")
+ Task { [asyncDelegate] in
+ await asyncDelegate.recordSystemExtensionState(
+ SystemExtensionState.failed(error.localizedDescription))
+ }
+ }
+
+ func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) {
+ logger.error("Extension \(request.identifier) requires user approval")
+ Task { [asyncDelegate] in
+ await asyncDelegate.recordSystemExtensionState(SystemExtensionState.needsUserApproval)
+ }
+ }
+
+ func request(
+ _ request: OSSystemExtensionRequest,
+ actionForReplacingExtension existing: OSSystemExtensionProperties,
+ withExtension extension: OSSystemExtensionProperties
+ ) -> OSSystemExtensionRequest.ReplacementAction {
+ // swiftlint:disable:next line_length
+ logger.info("Replacing \(request.identifier) v\(existing.bundleShortVersion) with v\(`extension`.bundleShortVersion)")
+ return .replace
+ }
+}
diff --git a/Coder Desktop/Coder Desktop/VPNService.swift b/Coder Desktop/Coder Desktop/VPNService.swift
index f19b4b9..cb5cf55 100644
--- a/Coder Desktop/Coder Desktop/VPNService.swift
+++ b/Coder Desktop/Coder Desktop/VPNService.swift
@@ -1,3 +1,5 @@
+import NetworkExtension
+import os
import SwiftUI
@MainActor
@@ -7,6 +9,7 @@ protocol VPNService: ObservableObject {
func start() async
// Stop must be idempotent
func stop() async
+ func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?)
}
enum VPNServiceState: Equatable {
@@ -18,13 +21,80 @@ enum VPNServiceState: Equatable {
}
enum VPNServiceError: Error, Equatable {
- // TODO:
- case exampleError
+ case internalError(String)
+ case systemExtensionError(SystemExtensionState)
+ case networkExtensionError(NetworkExtensionState)
+ case longTestError
var description: String {
switch self {
- case .exampleError:
+ case .longTestError:
return "This is a long error to test the UI with long errors"
+ case let .internalError(description):
+ return "Internal Error: \(description)"
+ case let .systemExtensionError(state):
+ return state.description
+ case let .networkExtensionError(state):
+ return state.description
+ }
+ }
+}
+
+@MainActor
+final class CoderVPNService: NSObject, VPNService {
+ var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "vpn")
+ @Published var tunnelState: VPNServiceState = .disabled
+ @Published var sysExtnState: SystemExtensionState = .uninstalled
+ @Published var neState: NetworkExtensionState = .unconfigured
+ var state: VPNServiceState {
+ guard sysExtnState == .installed else {
+ return .failed(.systemExtensionError(sysExtnState))
+ }
+ guard neState == .enabled || neState == .disbled else {
+ return .failed(.networkExtensionError(neState))
+ }
+ return tunnelState
+ }
+
+ @Published var agents: [Agent] = []
+
+ override init() {
+ super.init()
+ installSystemExtension()
+ }
+
+ func start() async {
+ tunnelState = .connecting
+ await enableNetworkExtension()
+
+ // TODO: enable communication with the NetworkExtension to track state and agents. For
+ // now, just pretend it worked...
+ tunnelState = .connected
+ }
+
+ func stop() async {
+ tunnelState = .disconnecting
+ await disableNetworkExtension()
+ // TODO: determine when the NetworkExtension is completely disconnected
+ tunnelState = .disabled
+ }
+
+ func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?) {
+ Task {
+ if proto != nil {
+ await configureNetworkExtension(proto: proto!)
+ // this just configures the VPN, it doesn't enable it
+ tunnelState = .disabled
+ } else {
+ do {
+ try await removeNetworkExtension()
+ neState = .unconfigured
+ tunnelState = .disabled
+ } catch {
+ logger.error("failed to remoing network extension: \(error)")
+ neState = .failed(error.localizedDescription)
+ }
+ }
}
}
}
diff --git a/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift b/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift
index 2292801..484cb3a 100644
--- a/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift
+++ b/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift
@@ -110,7 +110,7 @@ struct VPNMenuTests {
#expect(try !toggle.isOn())
vpn.onStart = {
- vpn.state = .failed(.exampleError)
+ vpn.state = .failed(.longTestError)
}
await vpn.start()
diff --git a/Coder Desktop/Coder DesktopTests/VPNStateTests.swift b/Coder Desktop/Coder DesktopTests/VPNStateTests.swift
index 89c241d..4d826a5 100644
--- a/Coder Desktop/Coder DesktopTests/VPNStateTests.swift
+++ b/Coder Desktop/Coder DesktopTests/VPNStateTests.swift
@@ -55,12 +55,12 @@ struct VPNStateTests {
@Test
func testFailedState() async throws {
- vpn.state = .failed(.exampleError)
+ vpn.state = .failed(.longTestError)
try await ViewHosting.host(view.environmentObject(vpn)) {
try await sut.inspection.inspect { view in
let text = try view.find(ViewType.Text.self)
- #expect(try text.string() == VPNServiceError.exampleError.description)
+ #expect(try text.string() == VPNServiceError.longTestError.description)
}
}
}
diff --git a/Coder Desktop/VPN/Info.plist b/Coder Desktop/VPN/Info.plist
index 7709a84..7bf9269 100644
--- a/Coder Desktop/VPN/Info.plist
+++ b/Coder Desktop/VPN/Info.plist
@@ -7,7 +7,7 @@
NetworkExtension
NEMachServiceName
- $(TeamIdentifierPrefix)com.example.app-group.MySystemExtension
+ $(TeamIdentifierPrefix)com.coder.Coder-Desktop.VPN
NEProviderClasses
com.apple.networkextension.packet-tunnel
diff --git a/Coder Desktop/VPN/PacketTunnelProvider.swift b/Coder Desktop/VPN/PacketTunnelProvider.swift
index a8424bf..8f3e3ca 100644
--- a/Coder Desktop/VPN/PacketTunnelProvider.swift
+++ b/Coder Desktop/VPN/PacketTunnelProvider.swift
@@ -5,7 +5,7 @@ import os
let CTLIOCGINFO: UInt = 0xC064_4E03
class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
- private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "packet-tunnel-provider")
+ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "provider")
private var manager: Manager?
public var tunnelFileDescriptor: Int32? {
@@ -41,6 +41,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
}
override func startTunnel(options _: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) {
+ logger.debug("startTunnel called")
guard manager == nil else {
logger.error("startTunnel called with non-nil Manager")
completionHandler(nil)
@@ -57,6 +58,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
}
override func stopTunnel(with _: NEProviderStopReason, completionHandler: @escaping () -> Void) {
+ logger.debug("stopTunnel called")
guard manager == nil else {
logger.error("stopTunnel called with nil Manager")
completionHandler()
@@ -75,10 +77,12 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
override func sleep(completionHandler: @escaping () -> Void) {
// Add code here to get ready to sleep.
+ logger.debug("sleep called")
completionHandler()
}
override func wake() {
// Add code here to wake up.
+ logger.debug("wake called")
}
}
diff --git a/Coder Desktop/VPN/VPN.entitlements b/Coder Desktop/VPN/VPN.entitlements
index b8ae396..c5befc9 100644
--- a/Coder Desktop/VPN/VPN.entitlements
+++ b/Coder Desktop/VPN/VPN.entitlements
@@ -2,18 +2,15 @@
+ com.apple.developer.networking.networkextension
+
+ packet-tunnel-provider
+
com.apple.security.app-sandbox
com.apple.security.application-groups
- $(TeamIdentifierPrefix)com.example.app-group
-
- com.apple.developer.networking.networkextension
-
- packet-tunnel-provider
- app-proxy-provider
- content-filter-provider
- dns-proxy
+ $(TeamIdentifierPrefix)com.coder.Coder-Desktop