diff --git a/Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj b/Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj index e68a8c4..a7b3592 100644 --- a/Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj +++ b/Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj @@ -17,7 +17,6 @@ AA3B3DCE2D2D249F0099996A /* VPNLib.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = AA3B3DA12D2D23860099996A /* VPNLib.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; AA3B3E8E2D2E0FF40099996A /* Mocker in Frameworks */ = {isa = PBXBuildFile; productRef = AA3B3E8D2D2E0FF40099996A /* Mocker */; }; AA3B40992D2FC8560099996A /* CoderSDK.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA3B40912D2FC8560099996A /* CoderSDK.framework */; }; - AA3B40A42D2FC8560099996A /* CoderSDK.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA3B40912D2FC8560099996A /* CoderSDK.framework */; }; AA3B40B62D2FD9DD0099996A /* Mocker in Frameworks */ = {isa = PBXBuildFile; productRef = AA3B40B52D2FD9DD0099996A /* Mocker */; }; AA3B40B72D2FDA5C0099996A /* CoderSDK.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA3B40912D2FC8560099996A /* CoderSDK.framework */; }; AA3B40BD2D2FDFBA0099996A /* Mocker in Frameworks */ = {isa = PBXBuildFile; productRef = AA3B40BC2D2FDFBA0099996A /* Mocker */; }; @@ -25,6 +24,10 @@ AA8BC3392D0060A900E1ABAA /* ViewInspector in Frameworks */ = {isa = PBXBuildFile; productRef = AA8BC3382D0060A900E1ABAA /* ViewInspector */; }; AA8BC33F2D0061F200E1ABAA /* FluidMenuBarExtra in Frameworks */ = {isa = PBXBuildFile; productRef = AA8BC33E2D0061F200E1ABAA /* FluidMenuBarExtra */; }; AA8BC4CF2D00A4B700E1ABAA /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = AA8BC4CE2D00A4B700E1ABAA /* KeychainAccess */; }; + AAC382352D427B7600F6DFB4 /* CoderSDK.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA3B40912D2FC8560099996A /* CoderSDK.framework */; }; + AAC382362D427B7600F6DFB4 /* CoderSDK.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = AA3B40912D2FC8560099996A /* CoderSDK.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + AAC382392D427B8300F6DFB4 /* CoderSDK.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA3B40912D2FC8560099996A /* CoderSDK.framework */; }; + AAC3823A2D427B8300F6DFB4 /* CoderSDK.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = AA3B40912D2FC8560099996A /* CoderSDK.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -105,6 +108,13 @@ remoteGlobalIDString = AA3B40902D2FC8560099996A; remoteInfo = CoderSDK; }; + AAC382372D427B7600F6DFB4 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 961678F42CFF100D00B2B6DF /* Project object */; + proxyType = 1; + remoteGlobalIDString = AA3B40902D2FC8560099996A; + remoteInfo = CoderSDK; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -126,6 +136,18 @@ dstSubfolderSpec = 10; files = ( AA3B3DCE2D2D249F0099996A /* VPNLib.framework in Embed Frameworks */, + AAC382362D427B7600F6DFB4 /* CoderSDK.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + AAC3823B2D427B8300F6DFB4 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + AAC3823A2D427B8300F6DFB4 /* CoderSDK.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -228,8 +250,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - AA3B40A42D2FC8560099996A /* CoderSDK.framework in Frameworks */, AA8BC4CF2D00A4B700E1ABAA /* KeychainAccess in Frameworks */, + AAC382392D427B8300F6DFB4 /* CoderSDK.framework in Frameworks */, AA2C690F2D34F6920059AFAF /* LaunchAtLogin in Frameworks */, AA8BC33F2D0061F200E1ABAA /* FluidMenuBarExtra in Frameworks */, ); @@ -257,6 +279,7 @@ buildActionMask = 2147483647; files = ( 961679332CFF117300B2B6DF /* NetworkExtension.framework in Frameworks */, + AAC382352D427B7600F6DFB4 /* CoderSDK.framework in Frameworks */, AA3B3DCD2D2D249F0099996A /* VPNLib.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -366,6 +389,7 @@ 961678F92CFF100D00B2B6DF /* Frameworks */, 961678FA2CFF100D00B2B6DF /* Resources */, 961679422CFF117300B2B6DF /* Embed System Extensions */, + AAC3823B2D427B8300F6DFB4 /* Embed Frameworks */, ); buildRules = ( ); @@ -452,6 +476,7 @@ dependencies = ( AA2C69922D354A8B0059AFAF /* PBXTargetDependency */, AA3B3DD02D2D249F0099996A /* PBXTargetDependency */, + AAC382382D427B7600F6DFB4 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( AA3C69AD2D2D143400A45481 /* VPN */, @@ -847,6 +872,11 @@ target = AA3B40902D2FC8560099996A /* CoderSDK */; targetProxy = AA3B40C22D2FE7760099996A /* PBXContainerItemProxy */; }; + AAC382382D427B7600F6DFB4 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = AA3B40902D2FC8560099996A /* CoderSDK */; + targetProxy = AAC382372D427B7600F6DFB4 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -1216,6 +1246,7 @@ buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = NO; CODE_SIGN_IDENTITY = ""; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; @@ -1324,6 +1355,7 @@ buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = NO; CODE_SIGN_IDENTITY = ""; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; diff --git a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift index c45e632..bfb01ce 100644 --- a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift +++ b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift @@ -11,10 +11,12 @@ struct DesktopApp: App { EmptyView() } Window("Sign In", id: Windows.login.rawValue) { - LoginForm().environmentObject(appDelegate.session) + LoginForm() + .environmentObject(appDelegate.session) + .environmentObject(appDelegate.settings) } .windowResizability(.contentSize) - SwiftUI.Settings { SettingsView() + SwiftUI.Settings { SettingsView() .environmentObject(appDelegate.vpn) .environmentObject(appDelegate.settings) } @@ -25,20 +27,20 @@ struct DesktopApp: App { @MainActor class AppDelegate: NSObject, NSApplicationDelegate { private var menuBarExtra: FluidMenuBarExtra? - let vpn: PreviewVPN - let session: PreviewSession + let vpn: CoderVPNService + let session: SecureSession let settings: Settings override init() { // TODO: Replace with real implementation - vpn = PreviewVPN() + vpn = CoderVPNService() settings = Settings() - session = PreviewSession() + session = SecureSession(onChange: vpn.configureTunnelProviderProtocol) } func applicationDidFinishLaunching(_: Notification) { menuBarExtra = FluidMenuBarExtra(title: "Coder Desktop", image: "MenuBarIcon") { - VPNMenu().frame(width: 256) + VPNMenu().frame(width: 256) .environmentObject(self.vpn) .environmentObject(self.session) .environmentObject(self.settings) diff --git a/Coder Desktop/Coder Desktop/NetworkExtension.swift b/Coder Desktop/Coder Desktop/NetworkExtension.swift index 4c29256..745579e 100644 --- a/Coder Desktop/Coder Desktop/NetworkExtension.swift +++ b/Coder Desktop/Coder Desktop/NetworkExtension.swift @@ -3,17 +3,17 @@ import os enum NetworkExtensionState: Equatable { case unconfigured - case disbled + case disabled case enabled case failed(String) var description: String { switch self { case .unconfigured: - return "Not logged in to Coder" + return "NetworkExtension not configured, try logging in again" case .enabled: return "NetworkExtension tunnel enabled" - case .disbled: + case .disabled: return "NetworkExtension tunnel disabled" case let .failed(error): return "NetworkExtension config failed: \(error)" @@ -24,6 +24,16 @@ enum NetworkExtensionState: Equatable { /// An actor that handles configuring, enabling, and disabling the VPN tunnel via the /// NetworkExtension APIs. extension CoderVPNService { + // Updates the UI if a previous configuration exists + func loadNetworkExtension() async { + do { + try await getTunnelManager() + neState = .disabled + } catch { + neState = .unconfigured + } + } + func configureNetworkExtension(proto: NETunnelProviderProtocol) async { // removing the old tunnels, rather than reconfiguring ensures that configuration changes // are picked up. @@ -47,6 +57,7 @@ extension CoderVPNService { logger.error("save tunnel failed: \(error)") neState = .failed(error.localizedDescription) } + neState = .disabled } func removeNetworkExtension() async throws(VPNServiceError) { @@ -91,9 +102,10 @@ extension CoderVPNService { return } logger.debug("saved tunnel with enabled=false") - neState = .disbled + neState = .disabled } + @discardableResult private func getTunnelManager() async throws(VPNServiceError) -> NETunnelProviderManager { var tunnels: [NETunnelProviderManager] = [] do { diff --git a/Coder Desktop/Coder Desktop/VPNService.swift b/Coder Desktop/Coder Desktop/VPNService.swift index cfc484b..4510634 100644 --- a/Coder Desktop/Coder Desktop/VPNService.swift +++ b/Coder Desktop/Coder Desktop/VPNService.swift @@ -50,7 +50,7 @@ final class CoderVPNService: NSObject, VPNService { guard sysExtnState == .installed else { return .failed(.systemExtensionError(sysExtnState)) } - guard neState == .enabled || neState == .disbled else { + guard neState == .enabled || neState == .disabled else { return .failed(.networkExtensionError(neState)) } return tunnelState @@ -66,6 +66,9 @@ final class CoderVPNService: NSObject, VPNService { override init() { super.init() installSystemExtension() + Task { + await loadNetworkExtension() + } } var startTask: Task? diff --git a/Coder Desktop/VPN/Manager.swift b/Coder Desktop/VPN/Manager.swift index bd598a0..b441150 100644 --- a/Coder Desktop/VPN/Manager.swift +++ b/Coder Desktop/VPN/Manager.swift @@ -16,13 +16,14 @@ actor Manager { .first!.appending(path: "coder-vpn.dylib") private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "manager") + // swiftlint:disable:next function_body_length init(with: PacketTunnelProvider, cfg: ManagerConfig) async throws(ManagerError) { ptp = with self.cfg = cfg #if arch(arm64) - let dylibPath = cfg.serverUrl.appending(path: "bin/coder-vpn-arm64.dylib") + let dylibPath = cfg.serverUrl.appending(path: "bin/coder-vpn-darwin-arm64.dylib") #elseif arch(x86_64) - let dylibPath = cfg.serverUrl.appending(path: "bin/coder-vpn-amd64.dylib") + let dylibPath = cfg.serverUrl.appending(path: "bin/coder-vpn-darwin-amd64.dylib") #else fatalError("unknown architecture") #endif @@ -60,6 +61,14 @@ actor Manager { } catch { throw .handshake(error) } + do { + try await tunnelHandle.openTunnelTask?.value + } catch let error as TunnelHandleError { + logger.error("failed to wait for dylib to open tunnel: \(error, privacy: .public) ") + throw .tunnelSetup(error) + } catch { + fatalError("openTunnelTask must only throw TunnelHandleError") + } readLoop = Task { try await run() } } @@ -180,7 +189,7 @@ actor Manager { } } -public struct ManagerConfig { +struct ManagerConfig { let apiToken: String let serverUrl: URL } @@ -195,6 +204,29 @@ enum ManagerError: Error { case serverInfo(String) case errorResponse(msg: String) case noTunnelFileDescriptor + + var description: String { + switch self { + case let .download(err): + return "Download error: \(err)" + case let .tunnelSetup(err): + return "Tunnel setup error: \(err)" + case let .handshake(err): + return "Handshake error: \(err)" + case let .validation(err): + return "Validation error: \(err)" + case .incorrectResponse: + return "Received unexpected response over tunnel" + case let .failedRPC(err): + return "Failed rpc: \(err)" + case let .serverInfo(msg): + return msg + case let .errorResponse(msg): + return msg + case .noTunnelFileDescriptor: + return "Could not find a tunnel file descriptor" + } + } } func writeVpnLog(_ log: Vpn_Log) { diff --git a/Coder Desktop/VPN/PacketTunnelProvider.swift b/Coder Desktop/VPN/PacketTunnelProvider.swift index 8f3e3ca..308882c 100644 --- a/Coder Desktop/VPN/PacketTunnelProvider.swift +++ b/Coder Desktop/VPN/PacketTunnelProvider.swift @@ -1,5 +1,6 @@ import NetworkExtension import os +import VPNLib /* From */ let CTLIOCGINFO: UInt = 0xC064_4E03 @@ -8,7 +9,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "provider") private var manager: Manager? - public var tunnelFileDescriptor: Int32? { + var tunnelFileDescriptor: Int32? { var ctlInfo = ctl_info() withUnsafeMutablePointer(to: &ctlInfo.ctl_name) { $0.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size(ofValue: $0.pointee)) { @@ -47,19 +48,25 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { completionHandler(nil) return } + let completionHandler = CallbackWrapper(completionHandler) Task { // TODO: Retrieve access URL & Token via Keychain - manager = try await Manager( - with: self, - cfg: .init(apiToken: "fake-token", serverUrl: .init(string: "https://dev.coder.com")!) - ) + do throws(ManagerError) { + manager = try await Manager( + with: self, + cfg: .init(apiToken: "fake-token", serverUrl: .init(string: "https://dev.coder.com")!) + ) + completionHandler(nil) + } catch { + completionHandler(error) + logger.error("error starting manager: \(error.description, privacy: .public)") + } } - completionHandler(nil) } override func stopTunnel(with _: NEProviderStopReason, completionHandler: @escaping () -> Void) { logger.debug("stopTunnel called") - guard manager == nil else { + guard manager != nil else { logger.error("stopTunnel called with nil Manager") completionHandler() return diff --git a/Coder Desktop/VPN/TunnelHandle.swift b/Coder Desktop/VPN/TunnelHandle.swift index 4258e1b..ea80002 100644 --- a/Coder Desktop/VPN/TunnelHandle.swift +++ b/Coder Desktop/VPN/TunnelHandle.swift @@ -13,6 +13,9 @@ actor TunnelHandle { var writeHandle: FileHandle { tunnelReadPipe.fileHandleForWriting } var readHandle: FileHandle { tunnelWritePipe.fileHandleForReading } + // MUST only ever throw TunnelHandleError + var openTunnelTask: Task? + init(dylibPath: URL) throws(TunnelHandleError) { guard let dylibHandle = dlopen(dylibPath.path, RTLD_NOW | RTLD_LOCAL) else { throw .dylib(dlerror().flatMap { String(cString: $0) } ?? "UNKNOWN") @@ -22,13 +25,22 @@ actor TunnelHandle { guard let startSym = dlsym(dylibHandle, startSymbol) else { throw .symbol(startSymbol, dlerror().flatMap { String(cString: $0) } ?? "UNKNOWN") } - let openTunnelFn = unsafeBitCast(startSym, to: OpenTunnel.self) + let openTunnelFn = SendableOpenTunnel(unsafeBitCast(startSym, to: OpenTunnel.self)) tunnelReadPipe = Pipe() tunnelWritePipe = Pipe() - let res = openTunnelFn(tunnelReadPipe.fileHandleForReading.fileDescriptor, - tunnelWritePipe.fileHandleForWriting.fileDescriptor) - guard res == 0 else { - throw .openTunnel(OpenTunnelError(rawValue: res) ?? .unknown) + let rfd = tunnelReadPipe.fileHandleForReading.fileDescriptor + let wfd = tunnelWritePipe.fileHandleForWriting.fileDescriptor + openTunnelTask = Task { [openTunnelFn] in + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + DispatchQueue.global().async { + let res = openTunnelFn(rfd, wfd) + guard res == 0 else { + cont.resume(throwing: TunnelHandleError.openTunnel(OpenTunnelError(rawValue: res) ?? .unknown)) + return + } + cont.resume() + } + } } } @@ -89,3 +101,14 @@ enum OpenTunnelError: Int32 { } } } + +struct SendableOpenTunnel: @unchecked Sendable { + let fn: OpenTunnel + init(_ function: OpenTunnel) { + fn = function + } + + func callAsFunction(_ lhs: Int32, _ rhs: Int32) -> Int32 { + fn(lhs, rhs) + } +} diff --git a/Coder Desktop/VPN/VPN.entitlements b/Coder Desktop/VPN/VPN.entitlements index c5befc9..a515bd3 100644 --- a/Coder Desktop/VPN/VPN.entitlements +++ b/Coder Desktop/VPN/VPN.entitlements @@ -12,5 +12,7 @@ $(TeamIdentifierPrefix)com.coder.Coder-Desktop + com.apple.security.network.client + diff --git a/Coder Desktop/VPNLib/Util.swift b/Coder Desktop/VPNLib/Util.swift new file mode 100644 index 0000000..9dbfbc7 --- /dev/null +++ b/Coder Desktop/VPNLib/Util.swift @@ -0,0 +1,12 @@ +public final class CallbackWrapper: @unchecked Sendable { + private let block: (T?) -> U + + public init(_ block: @escaping (T?) -> U) { + self.block = block + } + + public func callAsFunction(_ error: T?) -> U { + // Just forward to the original block + block(error) + } +}