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