From d80d709fa3fb0013ac3050ec41107f92966b514b Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 23 Jul 2025 14:14:19 +1000 Subject: [PATCH 1/2] chore: run coder connect networking from launchdaemon --- .../Coder-Desktop/VPN/VPNService.swift | 16 +- .../Coder-Desktop/Views/LoginForm.swift | 6 +- .../Coder-Desktop/XPCInterface.swift | 148 ++++++------- .../HelperXPCListeners.swift | 205 ++++++++++++++++++ .../HelperXPCProtocol.swift | 5 - .../Manager.swift | 68 +----- .../TunnelHandle.swift | 0 .../com.coder.Coder-Desktop.Helper.plist | 4 +- ..._coder_Coder_Desktop_VPN-Bridging-Header.h | 0 Coder-Desktop/Coder-DesktopHelper/main.swift | 76 +------ Coder-Desktop/VPN/AppXPCListener.swift | 43 ---- Coder-Desktop/VPN/HelperXPCSpeaker.swift | 102 ++++++--- Coder-Desktop/VPN/PacketTunnelProvider.swift | 92 ++------ Coder-Desktop/VPN/XPCInterface.swift | 34 --- Coder-Desktop/VPN/main.swift | 14 -- Coder-Desktop/VPNLib/XPC.swift | 53 ++++- Coder-Desktop/project.yml | 14 +- 17 files changed, 461 insertions(+), 419 deletions(-) create mode 100644 Coder-Desktop/Coder-DesktopHelper/HelperXPCListeners.swift delete mode 100644 Coder-Desktop/Coder-DesktopHelper/HelperXPCProtocol.swift rename Coder-Desktop/{VPN => Coder-DesktopHelper}/Manager.swift (80%) rename Coder-Desktop/{VPN => Coder-DesktopHelper}/TunnelHandle.swift (100%) rename Coder-Desktop/{VPN => Coder-DesktopHelper}/com_coder_Coder_Desktop_VPN-Bridging-Header.h (100%) delete mode 100644 Coder-Desktop/VPN/AppXPCListener.swift delete mode 100644 Coder-Desktop/VPN/XPCInterface.swift diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift index 224174ae..71e38884 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift @@ -54,7 +54,7 @@ enum VPNServiceError: Error, Equatable { @MainActor final class CoderVPNService: NSObject, VPNService { var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "vpn") - lazy var xpc: VPNXPCInterface = .init(vpn: self) + lazy var xpc: AppXPCListener = .init(vpn: self) @Published var tunnelState: VPNServiceState = .disabled { didSet { @@ -138,10 +138,10 @@ final class CoderVPNService: NSObject, VPNService { } } - func onExtensionPeerUpdate(_ data: Data) { + func onExtensionPeerUpdate(_ diff: Data) { logger.info("network extension peer update") do { - let msg = try Vpn_PeerUpdate(serializedBytes: data) + let msg = try Vpn_PeerUpdate(serializedBytes: diff) debugPrint(msg) applyPeerUpdate(with: msg) } catch { @@ -199,16 +199,18 @@ extension CoderVPNService { break // Non-connecting -> Connecting: Establish XPC case (_, .connecting): - xpc.connect() - xpc.ping() + // Detached to run ASAP + // TODO: Switch to `Task.immediate` once stable + Task.detached { try? await self.xpc.ping() } tunnelState = .connecting // Non-connected -> Connected: // - Retrieve Peers // - Run `onStart` closure case (_, .connected): onStart?() - xpc.connect() - xpc.getPeerState() + // Detached to run ASAP + // TODO: Switch to `Task.immediate` once stable + Task.detached { try? await self.xpc.getPeerState() } tunnelState = .connected // Any -> Reasserting case (_, .reasserting): diff --git a/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift b/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift index 5e9227ff..798a4727 100644 --- a/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift +++ b/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift @@ -192,13 +192,13 @@ struct LoginForm: View { @discardableResult func validateURL(_ url: String) throws(LoginError) -> URL { guard let url = URL(string: url) else { - throw LoginError.invalidURL + throw .invalidURL } guard url.scheme == "https" else { - throw LoginError.httpsRequired + throw .httpsRequired } guard url.host != nil else { - throw LoginError.noHost + throw .noHost } return url } diff --git a/Coder-Desktop/Coder-Desktop/XPCInterface.swift b/Coder-Desktop/Coder-Desktop/XPCInterface.swift index e6c78d6d..d6a54098 100644 --- a/Coder-Desktop/Coder-Desktop/XPCInterface.swift +++ b/Coder-Desktop/Coder-Desktop/XPCInterface.swift @@ -3,112 +3,98 @@ import NetworkExtension import os import VPNLib -@objc final class VPNXPCInterface: NSObject, VPNXPCClientCallbackProtocol, @unchecked Sendable { +@objc final class AppXPCListener: NSObject, AppXPCInterface, @unchecked Sendable { private var svc: CoderVPNService - private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VPNXPCInterface") - private var xpc: VPNXPCProtocol? + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "AppXPCListener") + private var connection: NSXPCConnection? init(vpn: CoderVPNService) { svc = vpn super.init() } - func connect() { - logger.debug("VPN xpc connect called") - guard xpc == nil else { - logger.debug("VPN xpc already exists") - return + func connect() -> NSXPCConnection { + if let connection { + return connection } - let networkExtDict = Bundle.main.object(forInfoDictionaryKey: "NetworkExtension") as? [String: Any] - let machServiceName = networkExtDict?["NEMachServiceName"] as? String - let xpcConn = NSXPCConnection(machServiceName: machServiceName!) - xpcConn.remoteObjectInterface = NSXPCInterface(with: VPNXPCProtocol.self) - xpcConn.exportedInterface = NSXPCInterface(with: VPNXPCClientCallbackProtocol.self) - guard let proxy = xpcConn.remoteObjectProxy as? VPNXPCProtocol else { - fatalError("invalid xpc cast") - } - xpc = proxy - - logger.debug("connecting to machServiceName: \(machServiceName!)") - xpcConn.exportedObject = self - xpcConn.invalidationHandler = { [logger] in - Task { @MainActor in - logger.error("VPN XPC connection invalidated.") - self.xpc = nil - self.connect() - } - } - xpcConn.interruptionHandler = { [logger] in - Task { @MainActor in - logger.error("VPN XPC connection interrupted.") - self.xpc = nil - self.connect() - } + let connection = NSXPCConnection( + machServiceName: helperAppMachServiceName, + options: .privileged + ) + connection.remoteObjectInterface = NSXPCInterface(with: HelperAppXPCInterface.self) + connection.exportedInterface = NSXPCInterface(with: AppXPCInterface.self) + connection.exportedObject = self + connection.invalidationHandler = { + self.logger.error("XPC connection invalidated") + self.connection = nil + _ = self.connect() } - xpcConn.resume() - } - - func ping() { - xpc?.ping { - Task { @MainActor in - self.logger.info("Connected to NE over XPC") - } + connection.interruptionHandler = { + self.logger.error("XPC connection interrupted") + self.connection = nil + _ = self.connect() } + logger.info("connecting to \(helperAppMachServiceName)") + connection.resume() + self.connection = connection + return connection } - func getPeerState() { - xpc?.getPeerState { data in - Task { @MainActor in - self.svc.onExtensionPeerState(data) - } + func onPeerUpdate(_ diff: Data, reply: @escaping () -> Void) { + let reply = CompletionWrapper(reply) + Task { @MainActor in + svc.onExtensionPeerUpdate(diff) + reply() } } - func onPeerUpdate(_ data: Data) { + func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?, reply: @escaping () -> Void) { + let reply = CompletionWrapper(reply) Task { @MainActor in - svc.onExtensionPeerUpdate(data) + svc.onProgress(stage: stage, downloadProgress: downloadProgress) + reply() } } +} - func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?) { - Task { @MainActor in - svc.onProgress(stage: stage, downloadProgress: downloadProgress) +// These methods are called to request updatess from the Helper. +extension AppXPCListener { + func ping() async throws { + let conn = connect() + return try await withCheckedThrowingContinuation { continuation in + guard let proxy = conn.remoteObjectProxyWithErrorHandler({ err in + self.logger.error("failed to connect to HelperXPC \(err.localizedDescription, privacy: .public)") + continuation.resume(throwing: err) + }) as? HelperAppXPCInterface else { + self.logger.error("failed to get proxy for HelperXPC") + continuation.resume(throwing: XPCError.wrongProxyType) + return + } + proxy.ping { + self.logger.info("Connected to Helper over XPC") + continuation.resume() + } } } - // The NE has verified the dylib and knows better than Gatekeeper - func removeQuarantine(path: String, reply: @escaping (Bool) -> Void) { - let reply = CallbackWrapper(reply) - Task { @MainActor in - let prompt = """ - Coder Desktop wants to execute code downloaded from \ - \(svc.serverAddress ?? "the Coder deployment"). The code has been \ - verified to be signed by Coder. - """ - let source = """ - do shell script "xattr -d com.apple.quarantine \(path)" \ - with prompt "\(prompt)" \ - with administrator privileges - """ - let success = await withCheckedContinuation { continuation in - guard let script = NSAppleScript(source: source) else { - continuation.resume(returning: false) - return - } - // Run on a background thread - Task.detached { - var error: NSDictionary? - script.executeAndReturnError(&error) - if let error { - self.logger.error("AppleScript error: \(error)") - continuation.resume(returning: false) - } else { - continuation.resume(returning: true) - } + func getPeerState() async throws { + let conn = connect() + return try await withCheckedThrowingContinuation { continuation in + guard let proxy = conn.remoteObjectProxyWithErrorHandler({ err in + self.logger.error("failed to connect to HelperXPC \(err.localizedDescription, privacy: .public)") + continuation.resume(throwing: err) + }) as? HelperAppXPCInterface else { + self.logger.error("failed to get proxy for HelperXPC") + continuation.resume(throwing: XPCError.wrongProxyType) + return + } + proxy.getPeerState { data in + Task { @MainActor in + self.svc.onExtensionPeerState(data) } + continuation.resume() } - reply(success) } } } diff --git a/Coder-Desktop/Coder-DesktopHelper/HelperXPCListeners.swift b/Coder-Desktop/Coder-DesktopHelper/HelperXPCListeners.swift new file mode 100644 index 00000000..6ed32336 --- /dev/null +++ b/Coder-Desktop/Coder-DesktopHelper/HelperXPCListeners.swift @@ -0,0 +1,205 @@ +import CoderSDK +import Foundation +import os +import VPNLib + +// This listener handles XPC connections from the Coder Desktop System Network +// Extension (`com.coder.Coder-Desktop.VPN`). +class HelperNEXPCListener: NSObject, NSXPCListenerDelegate, HelperNEXPCInterface, @unchecked Sendable { + private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperNEXPCListener") + private var conns: [NSXPCConnection] = [] + + // Hold a reference to the tun file handle + // to prevent it from being closed. + private var tunFile: FileHandle? + + override init() { + super.init() + } + + func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { + logger.info("new active connection") + newConnection.exportedInterface = NSXPCInterface(with: HelperNEXPCInterface.self) + newConnection.exportedObject = self + newConnection.remoteObjectInterface = NSXPCInterface(with: NEXPCInterface.self) + newConnection.invalidationHandler = { [weak self] in + guard let self else { return } + conns.removeAll { $0 == newConnection } + logger.debug("connection invalidated") + } + newConnection.interruptionHandler = { [weak self] in + guard let self else { return } + conns.removeAll { $0 == newConnection } + logger.debug("connection interrupted") + } + newConnection.resume() + conns.append(newConnection) + return true + } + + func startDaemon( + accessURL: URL, + token: String, + tun: FileHandle, + headers: Data?, + reply: @escaping (Error?) -> Void + ) { + logger.info("startDaemon called") + tunFile = tun + let reply = CallbackWrapper(reply) + Task { @MainActor in + do throws(ManagerError) { + let manager = try await Manager( + cfg: .init( + apiToken: token, + serverUrl: accessURL, + tunFd: tun.fileDescriptor, + literalHeaders: headers.flatMap { try? JSONDecoder().decode([HTTPHeader].self, from: $0) } ?? [] + ) + ) + try await manager.startVPN() + globalManager = manager + } catch { + reply(makeNSError(suffix: "Manager", desc: error.description)) + return + } + reply(nil) + } + } + + func stopDaemon(reply: @escaping (Error?) -> Void) { + logger.info("stopDaemon called") + let reply = CallbackWrapper(reply) + Task { @MainActor in + guard let manager = globalManager else { + logger.error("stopDaemon called with nil Manager") + reply(makeNSError(suffix: "Manager", desc: "Missing Manager")) + return + } + do throws(ManagerError) { + try await manager.stopVPN() + } catch { + reply(makeNSError(suffix: "Manager", desc: error.description)) + return + } + globalManager = nil + reply(nil) + } + } +} + +// These methods are called to send updates to the Coder Desktop System Network +// Extension. +extension HelperNEXPCListener { + func cancelProvider(error: Error?) async throws { + try await withCheckedThrowingContinuation { continuation in + guard let proxy = conns.last?.remoteObjectProxyWithErrorHandler({ err in + self.logger.error("failed to connect to HelperNEXPC \(err.localizedDescription, privacy: .public)") + continuation.resume(throwing: err) + }) as? NEXPCInterface else { + self.logger.error("failed to get proxy for HelperNEXPCInterface") + continuation.resume(throwing: XPCError.wrongProxyType) + return + } + proxy.cancelProvider(error: error) { + self.logger.info("provider cancelled") + continuation.resume() + } + } as Void + } + + func applyTunnelNetworkSettings(diff: Vpn_NetworkSettingsRequest) async throws { + let bytes = try diff.serializedData() + return try await withCheckedThrowingContinuation { continuation in + guard let proxy = conns.last?.remoteObjectProxyWithErrorHandler({ err in + self.logger.error("failed to connect to HelperNEXPC \(err.localizedDescription, privacy: .public)") + continuation.resume(throwing: err) + }) as? NEXPCInterface else { + self.logger.error("failed to get proxy for HelperNEXPCInterface") + continuation.resume(throwing: XPCError.wrongProxyType) + return + } + proxy.applyTunnelNetworkSettings(diff: bytes) { + self.logger.info("applied tunnel network setting") + continuation.resume() + } + } + } +} + +// This listener handles XPC connections from the Coder Desktop App +// (`com.coder.Coder-Desktop`). +class HelperAppXPCListener: NSObject, NSXPCListenerDelegate, HelperAppXPCInterface, @unchecked Sendable { + private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperAppXPCListener") + private var conns: [NSXPCConnection] = [] + + override init() { + super.init() + } + + func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { + logger.info("new app connection") + newConnection.exportedInterface = NSXPCInterface(with: HelperAppXPCInterface.self) + newConnection.exportedObject = self + newConnection.remoteObjectInterface = NSXPCInterface(with: AppXPCInterface.self) + newConnection.invalidationHandler = { [weak self] in + guard let self else { return } + conns.removeAll { $0 == newConnection } + logger.debug("app connection invalidated") + } + newConnection.resume() + conns.append(newConnection) + return true + } + + func getPeerState(with reply: @escaping (Data?) -> Void) { + logger.info("getPeerState called") + let reply = CallbackWrapper(reply) + Task { @MainActor in + let data = try? await globalManager?.getPeerState().serializedData() + reply(data) + } + } + + func ping(reply: @escaping () -> Void) { + reply() + } +} + +// These methods are called to send updates to the Coder Desktop App. +extension HelperAppXPCListener { + func onPeerUpdate(update: Vpn_PeerUpdate) async throws { + let bytes = try update.serializedData() + return try await withCheckedThrowingContinuation { continuation in + guard let proxy = conns.last?.remoteObjectProxyWithErrorHandler({ err in + self.logger.error("failed to connect to HelperAppXPC \(err.localizedDescription, privacy: .public)") + continuation.resume(throwing: err) + }) as? AppXPCInterface else { + self.logger.error("failed to get proxy for HelperAppXPCInterface") + continuation.resume(throwing: XPCError.wrongProxyType) + return + } + proxy.onPeerUpdate(bytes) { + self.logger.info("sent peer update") + continuation.resume() + } + } + } + + func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?) async throws { + try await withCheckedThrowingContinuation { continuation in + guard let proxy = conns.last?.remoteObjectProxyWithErrorHandler({ err in + self.logger.error("failed to connect to HelperAppXPC \(err.localizedDescription, privacy: .public)") + continuation.resume(throwing: err) + }) as? AppXPCInterface else { + self.logger.error("failed to get proxy for HelperAppXPCInterface") + continuation.resume(throwing: XPCError.wrongProxyType) + return + } + proxy.onProgress(stage: stage, downloadProgress: downloadProgress) { + self.logger.info("sent progress update") + continuation.resume() + } + } as Void + } +} diff --git a/Coder-Desktop/Coder-DesktopHelper/HelperXPCProtocol.swift b/Coder-Desktop/Coder-DesktopHelper/HelperXPCProtocol.swift deleted file mode 100644 index 5ffed59a..00000000 --- a/Coder-Desktop/Coder-DesktopHelper/HelperXPCProtocol.swift +++ /dev/null @@ -1,5 +0,0 @@ -import Foundation - -@objc protocol HelperXPCProtocol { - func removeQuarantine(path: String, withReply reply: @escaping (Int32, String) -> Void) -} diff --git a/Coder-Desktop/VPN/Manager.swift b/Coder-Desktop/Coder-DesktopHelper/Manager.swift similarity index 80% rename from Coder-Desktop/VPN/Manager.swift rename to Coder-Desktop/Coder-DesktopHelper/Manager.swift index 952e301e..e2d47b8b 100644 --- a/Coder-Desktop/VPN/Manager.swift +++ b/Coder-Desktop/Coder-DesktopHelper/Manager.swift @@ -4,7 +4,6 @@ import os import VPNLib actor Manager { - let ptp: PacketTunnelProvider let cfg: ManagerConfig let telemetryEnricher: TelemetryEnricher @@ -12,13 +11,13 @@ actor Manager { let speaker: Speaker var readLoop: Task! - private let dest = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + // /var/root/Downloads + private let dest = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask) .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 + init(cfg: ManagerConfig) async throws(ManagerError) { self.cfg = cfg telemetryEnricher = TelemetryEnricher() #if arch(arm64) @@ -62,10 +61,6 @@ actor Manager { throw .validation(error) } - // HACK: The downloaded dylib may be quarantined, but we've validated it's signature - // so it's safe to execute. However, the SE must be sandboxed, so we defer to the app. - try await removeQuarantine(dest) - do { try tunnelHandle = TunnelHandle(dylibPath: dest) } catch { @@ -105,14 +100,14 @@ actor Manager { } catch { logger.error("tunnel read loop failed: \(error.localizedDescription, privacy: .public)") try await tunnelHandle.close() - ptp.cancelTunnelWithError( + try await NEXPCListenerDelegate.cancelProvider(error: makeNSError(suffix: "Manager", desc: "Tunnel read loop failed: \(error.localizedDescription)") ) return } logger.info("tunnel read loop exited") try await tunnelHandle.close() - ptp.cancelTunnelWithError(nil) + try await NEXPCListenerDelegate.cancelProvider(error: nil) } func handleMessage(_ msg: Vpn_TunnelMessage) { @@ -122,14 +117,7 @@ actor Manager { } switch msgType { case .peerUpdate: - if let conn = globalXPCListenerDelegate.conn { - do { - let data = try msg.peerUpdate.serializedData() - conn.onPeerUpdate(data) - } catch { - logger.error("failed to send peer update to client: \(error)") - } - } + Task { try? await appXPCListenerDelegate.onPeerUpdate(update: msg.peerUpdate) } case let .log(logMsg): writeVpnLog(logMsg) case .networkSettings, .start, .stop: @@ -145,7 +133,7 @@ actor Manager { switch msgType { case let .networkSettings(ns): do { - try await ptp.applyTunnelNetworkSettings(ns) + try await NEXPCListenerDelegate.applyTunnelNetworkSettings(diff: ns) try? await rpc.sendReply(.with { resp in resp.networkSettings = .with { settings in settings.success = true @@ -167,16 +155,12 @@ actor Manager { func startVPN() async throws(ManagerError) { pushProgress(stage: .startingTunnel) logger.info("sending start rpc") - guard let tunFd = ptp.tunnelFileDescriptor else { - logger.error("no fd") - throw .noTunnelFileDescriptor - } let resp: Vpn_TunnelMessage do { resp = try await speaker.unaryRPC( .with { msg in msg.start = .with { req in - req.tunnelFileDescriptor = tunFd + req.tunnelFileDescriptor = cfg.tunFd req.apiToken = cfg.apiToken req.coderURL = cfg.serverUrl.absoluteString req.headers = cfg.literalHeaders.map { header in @@ -243,17 +227,13 @@ actor Manager { } func pushProgress(stage: ProgressStage, downloadProgress: DownloadProgress? = nil) { - guard let conn = globalXPCListenerDelegate.conn else { - logger.warning("couldn't send progress message to app: no connection") - return - } - logger.debug("sending progress message to app") - conn.onProgress(stage: stage, downloadProgress: downloadProgress) + Task { try? await appXPCListenerDelegate.onProgress(stage: stage, downloadProgress: downloadProgress) } } struct ManagerConfig { let apiToken: String let serverUrl: URL + let tunFd: Int32 let literalHeaders: [HTTPHeader] } @@ -323,31 +303,3 @@ func writeVpnLog(_ log: Vpn_Log) { let fields = log.fields.map { "\($0.name): \($0.value)" }.joined(separator: ", ") logger.log(level: level, "\(log.message, privacy: .public)\(fields.isEmpty ? "" : ": \(fields)", privacy: .public)") } - -private func removeQuarantine(_ dest: URL) async throws(ManagerError) { - var flag: AnyObject? - let file = NSURL(fileURLWithPath: dest.path) - try? file.getResourceValue(&flag, forKey: kCFURLQuarantinePropertiesKey as URLResourceKey) - if flag != nil { - pushProgress(stage: .removingQuarantine) - // Try the privileged helper first (it may not even be registered) - if await globalHelperXPCSpeaker.tryRemoveQuarantine(path: dest.path) { - // Success! - return - } - // Then try the app - guard let conn = globalXPCListenerDelegate.conn else { - // If neither are available, we can't execute the dylib - throw .noApp - } - // Wait for unsandboxed app to accept our file - let success = await withCheckedContinuation { [dest] continuation in - conn.removeQuarantine(path: dest.path) { success in - continuation.resume(returning: success) - } - } - if !success { - throw .permissionDenied - } - } -} diff --git a/Coder-Desktop/VPN/TunnelHandle.swift b/Coder-Desktop/Coder-DesktopHelper/TunnelHandle.swift similarity index 100% rename from Coder-Desktop/VPN/TunnelHandle.swift rename to Coder-Desktop/Coder-DesktopHelper/TunnelHandle.swift diff --git a/Coder-Desktop/Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist b/Coder-Desktop/Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist index f2309a19..fdecff2c 100644 --- a/Coder-Desktop/Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist +++ b/Coder-Desktop/Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist @@ -9,7 +9,9 @@ MachServices - 4399GN35BJ.com.coder.Coder-Desktop.Helper + 4399GN35BJ.com.coder.Coder-Desktop.HelperNE + + 4399GN35BJ.com.coder.Coder-Desktop.HelperApp AssociatedBundleIdentifiers diff --git a/Coder-Desktop/VPN/com_coder_Coder_Desktop_VPN-Bridging-Header.h b/Coder-Desktop/Coder-DesktopHelper/com_coder_Coder_Desktop_VPN-Bridging-Header.h similarity index 100% rename from Coder-Desktop/VPN/com_coder_Coder_Desktop_VPN-Bridging-Header.h rename to Coder-Desktop/Coder-DesktopHelper/com_coder_Coder_Desktop_VPN-Bridging-Header.h diff --git a/Coder-Desktop/Coder-DesktopHelper/main.swift b/Coder-Desktop/Coder-DesktopHelper/main.swift index 0e94af21..9fc3879d 100644 --- a/Coder-Desktop/Coder-DesktopHelper/main.swift +++ b/Coder-Desktop/Coder-DesktopHelper/main.swift @@ -1,72 +1,18 @@ +import CoderSDK import Foundation import os +import VPNLib -class HelperToolDelegate: NSObject, NSXPCListenerDelegate, HelperXPCProtocol { - private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperToolDelegate") +var globalManager: Manager? - override init() { - super.init() - } +let NEXPCListenerDelegate = HelperNEXPCListener() +let NEXPCListener = NSXPCListener(machServiceName: helperNEMachServiceName) +NEXPCListener.delegate = NEXPCListenerDelegate +NEXPCListener.resume() - func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { - newConnection.exportedInterface = NSXPCInterface(with: HelperXPCProtocol.self) - newConnection.exportedObject = self - newConnection.invalidationHandler = { [weak self] in - self?.logger.info("Helper XPC connection invalidated") - } - newConnection.interruptionHandler = { [weak self] in - self?.logger.debug("Helper XPC connection interrupted") - } - logger.info("new active connection") - newConnection.resume() - return true - } +let appXPCListenerDelegate = HelperAppXPCListener() +let appXPCListener = NSXPCListener(machServiceName: helperAppMachServiceName) +appXPCListener.delegate = appXPCListenerDelegate +appXPCListener.resume() - func removeQuarantine(path: String, withReply reply: @escaping (Int32, String) -> Void) { - guard isCoderDesktopDylib(at: path) else { - reply(1, "Path is not to a Coder Desktop dylib: \(path)") - return - } - - let task = Process() - let pipe = Pipe() - - task.standardOutput = pipe - task.standardError = pipe - task.arguments = ["-d", "com.apple.quarantine", path] - task.executableURL = URL(fileURLWithPath: "/usr/bin/xattr") - - do { - try task.run() - } catch { - reply(1, "Failed to start command: \(error)") - return - } - - let data = pipe.fileHandleForReading.readDataToEndOfFile() - let output = String(data: data, encoding: .utf8) ?? "" - - task.waitUntilExit() - reply(task.terminationStatus, output) - } -} - -func isCoderDesktopDylib(at rawPath: String) -> Bool { - let url = URL(fileURLWithPath: rawPath) - .standardizedFileURL - .resolvingSymlinksInPath() - - // *Must* be within the Coder Desktop System Extension sandbox - let requiredPrefix = ["/", "var", "root", "Library", "Containers", - "com.coder.Coder-Desktop.VPN"] - guard url.pathComponents.starts(with: requiredPrefix) else { return false } - guard url.pathExtension.lowercased() == "dylib" else { return false } - guard FileManager.default.fileExists(atPath: url.path) else { return false } - return true -} - -let delegate = HelperToolDelegate() -let listener = NSXPCListener(machServiceName: "4399GN35BJ.com.coder.Coder-Desktop.Helper") -listener.delegate = delegate -listener.resume() RunLoop.main.run() diff --git a/Coder-Desktop/VPN/AppXPCListener.swift b/Coder-Desktop/VPN/AppXPCListener.swift deleted file mode 100644 index 3d77f01e..00000000 --- a/Coder-Desktop/VPN/AppXPCListener.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation -import NetworkExtension -import os -import VPNLib - -final class AppXPCListener: NSObject, NSXPCListenerDelegate, @unchecked Sendable { - let vpnXPCInterface = XPCInterface() - private var activeConnection: NSXPCConnection? - private var connMutex: NSLock = .init() - - var conn: VPNXPCClientCallbackProtocol? { - connMutex.lock() - defer { connMutex.unlock() } - - let conn = activeConnection?.remoteObjectProxy as? VPNXPCClientCallbackProtocol - return conn - } - - func setActiveConnection(_ connection: NSXPCConnection?) { - connMutex.lock() - defer { connMutex.unlock() } - activeConnection = connection - } - - func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { - newConnection.exportedInterface = NSXPCInterface(with: VPNXPCProtocol.self) - newConnection.exportedObject = vpnXPCInterface - newConnection.remoteObjectInterface = NSXPCInterface(with: VPNXPCClientCallbackProtocol.self) - newConnection.invalidationHandler = { [weak self] in - logger.info("active connection dead") - self?.setActiveConnection(nil) - } - newConnection.interruptionHandler = { [weak self] in - logger.debug("connection interrupted") - self?.setActiveConnection(nil) - } - logger.info("new active connection") - setActiveConnection(newConnection) - - newConnection.resume() - return true - } -} diff --git a/Coder-Desktop/VPN/HelperXPCSpeaker.swift b/Coder-Desktop/VPN/HelperXPCSpeaker.swift index 77de1f3a..0b5c6c45 100644 --- a/Coder-Desktop/VPN/HelperXPCSpeaker.swift +++ b/Coder-Desktop/VPN/HelperXPCSpeaker.swift @@ -1,33 +1,12 @@ import Foundation import os +import VPNLib -final class HelperXPCSpeaker: @unchecked Sendable { +final class HelperXPCSpeaker: NEXPCInterface, @unchecked Sendable { + var ptp: PacketTunnelProvider? private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperXPCSpeaker") private var connection: NSXPCConnection? - func tryRemoveQuarantine(path: String) async -> Bool { - let conn = connect() - return await withCheckedContinuation { continuation in - guard let proxy = conn.remoteObjectProxyWithErrorHandler({ err in - self.logger.error("Failed to connect to HelperXPC \(err)") - continuation.resume(returning: false) - }) as? HelperXPCProtocol else { - self.logger.error("Failed to get proxy for HelperXPC") - continuation.resume(returning: false) - return - } - proxy.removeQuarantine(path: path) { status, output in - if status == 0 { - self.logger.info("Successfully removed quarantine for \(path)") - continuation.resume(returning: true) - } else { - self.logger.error("Failed to remove quarantine for \(path): \(output)") - continuation.resume(returning: false) - } - } - } - } - private func connect() -> NSXPCConnection { if let connection = self.connection { return connection @@ -38,10 +17,12 @@ final class HelperXPCSpeaker: @unchecked Sendable { // the team identifier. // https://developer.apple.com/forums/thread/654466 let connection = NSXPCConnection( - machServiceName: "4399GN35BJ.com.coder.Coder-Desktop.Helper", + machServiceName: helperNEMachServiceName, options: .privileged ) - connection.remoteObjectInterface = NSXPCInterface(with: HelperXPCProtocol.self) + connection.remoteObjectInterface = NSXPCInterface(with: HelperNEXPCInterface.self) + connection.exportedInterface = NSXPCInterface(with: NEXPCInterface.self) + connection.exportedObject = self connection.invalidationHandler = { [weak self] in self?.connection = nil } @@ -52,4 +33,73 @@ final class HelperXPCSpeaker: @unchecked Sendable { self.connection = connection return connection } + + func applyTunnelNetworkSettings(diff: Data, reply: @escaping () -> Void) { + let reply = CompletionWrapper(reply) + guard let diff = try? Vpn_NetworkSettingsRequest(serializedBytes: diff) else { + reply() + return + } + Task { + try? await ptp?.applyTunnelNetworkSettings(diff) + reply() + } + } + + func cancelProvider(error: Error?, reply: @escaping () -> Void) { + let reply = CompletionWrapper(reply) + Task { + ptp?.cancelTunnelWithError(error) + reply() + } + } +} + +// These methods are called to start and stop the daemon run by the Helper. +extension HelperXPCSpeaker { + func startDaemon(accessURL: URL, token: String, tun: FileHandle, headers: Data?) async throws { + let conn = connect() + return try await withCheckedThrowingContinuation { continuation in + guard let proxy = conn.remoteObjectProxyWithErrorHandler({ err in + self.logger.error("failed to connect to HelperXPC \(err.localizedDescription, privacy: .public)") + continuation.resume(throwing: err) + }) as? HelperNEXPCInterface else { + self.logger.error("failed to get proxy for HelperXPC") + continuation.resume(throwing: XPCError.wrongProxyType) + return + } + proxy.startDaemon(accessURL: accessURL, token: token, tun: tun, headers: headers) { err in + if let error = err { + self.logger.error("Failed to start daemon: \(error.localizedDescription, privacy: .public)") + continuation.resume(throwing: error) + } else { + self.logger.info("successfully started daemon") + continuation.resume() + } + } + } + } + + func stopDaemon() async throws { + let conn = connect() + return try await withCheckedThrowingContinuation { continuation in + guard let proxy = conn.remoteObjectProxyWithErrorHandler({ err in + self.logger.error("failed to connect to HelperXPC \(err)") + continuation.resume(throwing: err) + }) as? HelperNEXPCInterface else { + self.logger.error("failed to get proxy for HelperXPC") + continuation.resume(throwing: XPCError.wrongProxyType) + return + } + proxy.stopDaemon { err in + if let error = err { + self.logger.error("failed to stop daemon: \(error.localizedDescription)") + continuation.resume(throwing: error) + } else { + self.logger.info("Successfully stopped daemon") + continuation.resume() + } + } + } + } } diff --git a/Coder-Desktop/VPN/PacketTunnelProvider.swift b/Coder-Desktop/VPN/PacketTunnelProvider.swift index 140cb5cc..6f54381a 100644 --- a/Coder-Desktop/VPN/PacketTunnelProvider.swift +++ b/Coder-Desktop/VPN/PacketTunnelProvider.swift @@ -8,7 +8,6 @@ let CTLIOCGINFO: UInt = 0xC064_4E03 class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "provider") - private var manager: Manager? // a `tunnelRemoteAddress` is required, but not currently used. private var currentSettings: NEPacketTunnelNetworkSettings = .init(tunnelRemoteAddress: "127.0.0.1") @@ -45,90 +44,41 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { } override func startTunnel( - options _: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void - ) { - logger.info("startTunnel called") - guard manager == nil else { - logger.error("startTunnel called with non-nil Manager") - // If the tunnel is already running, then we can just mark as connected. - completionHandler(nil) - return - } - start(completionHandler) - } - - // called by `startTunnel` - func start(_ completionHandler: @escaping (Error?) -> Void) { + options _: [String: NSObject]? + ) async throws { + globalHelperXPCSpeaker.ptp = self guard let proto = protocolConfiguration as? NETunnelProviderProtocol, let baseAccessURL = proto.serverAddress else { logger.error("startTunnel called with nil protocolConfiguration") - completionHandler(makeNSError(suffix: "PTP", desc: "Missing Configuration")) - return + throw makeNSError(suffix: "PTP", desc: "Missing Configuration") } // HACK: We can't write to the system keychain, and the NE can't read the user keychain. guard let token = proto.providerConfiguration?["token"] as? String else { logger.error("startTunnel called with nil token") - completionHandler(makeNSError(suffix: "PTP", desc: "Missing Token")) - return + throw makeNSError(suffix: "PTP", desc: "Missing Token") } - let headers: [HTTPHeader] = (proto.providerConfiguration?["literalHeaders"] as? Data) - .flatMap { try? JSONDecoder().decode([HTTPHeader].self, from: $0) } ?? [] + let headers = proto.providerConfiguration?["literalHeaders"] as? Data logger.debug("retrieved token & access URL") - let completionHandler = CallbackWrapper(completionHandler) - Task { - do throws(ManagerError) { - logger.debug("creating manager") - let manager = try await Manager( - with: self, - cfg: .init( - apiToken: token, serverUrl: .init(string: baseAccessURL)!, - literalHeaders: headers - ) - ) - globalXPCListenerDelegate.vpnXPCInterface.manager = manager - logger.debug("starting vpn") - try await manager.startVPN() - logger.info("vpn started") - self.manager = manager - completionHandler(nil) - } catch { - logger.error("error starting manager: \(error.description, privacy: .public)") - completionHandler( - makeNSError(suffix: "Manager", desc: error.description) - ) - } + guard let tunFd = tunnelFileDescriptor else { + logger.error("startTunnel called with nil tunnelFileDescriptor") + throw makeNSError(suffix: "PTP", desc: "Missing Tunnel File Descriptor") } + try await globalHelperXPCSpeaker.startDaemon( + accessURL: .init(string: baseAccessURL)!, + token: token, + tun: FileHandle(fileDescriptor: tunFd), + headers: headers + ) } override func stopTunnel( - with _: NEProviderStopReason, completionHandler: @escaping () -> Void - ) { - logger.debug("stopTunnel called") - teardown(completionHandler) - } - - // called by `stopTunnel` - func teardown(_ completionHandler: @escaping () -> Void) { - guard let manager else { - logger.error("teardown called with nil Manager") - completionHandler() - return - } - - let completionHandler = CompletionWrapper(completionHandler) - Task { [manager] in - do throws(ManagerError) { - try await manager.stopVPN() - } catch { - logger.error("error stopping manager: \(error.description, privacy: .public)") - } - globalXPCListenerDelegate.vpnXPCInterface.manager = nil - // Mark teardown as complete by setting manager to nil, and - // calling the completion handler. - self.manager = nil - completionHandler() - } + with _: NEProviderStopReason + ) async { + logger.debug("stopping tunnel") + try? await globalHelperXPCSpeaker.stopDaemon() + logger.info("tunnel stopped") + globalHelperXPCSpeaker.ptp = nil } override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) { diff --git a/Coder-Desktop/VPN/XPCInterface.swift b/Coder-Desktop/VPN/XPCInterface.swift deleted file mode 100644 index d83f7d79..00000000 --- a/Coder-Desktop/VPN/XPCInterface.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation -import os.log -import VPNLib - -@objc final class XPCInterface: NSObject, VPNXPCProtocol, @unchecked Sendable { - private var lockedManager: Manager? - private let managerLock = NSLock() - private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VPNXPCInterface") - - var manager: Manager? { - get { - managerLock.lock() - defer { managerLock.unlock() } - return lockedManager - } - set { - managerLock.lock() - defer { managerLock.unlock() } - lockedManager = newValue - } - } - - func getPeerState(with reply: @escaping (Data?) -> Void) { - let reply = CallbackWrapper(reply) - Task { - let data = try? await manager?.getPeerState().serializedData() - reply(data) - } - } - - func ping(with reply: @escaping () -> Void) { - reply() - } -} diff --git a/Coder-Desktop/VPN/main.swift b/Coder-Desktop/VPN/main.swift index bf6c371a..3255f395 100644 --- a/Coder-Desktop/VPN/main.swift +++ b/Coder-Desktop/VPN/main.swift @@ -5,24 +5,10 @@ import VPNLib let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "provider") -guard - let netExt = Bundle.main.object(forInfoDictionaryKey: "NetworkExtension") as? [String: Any], - let serviceName = netExt["NEMachServiceName"] as? String -else { - fatalError("Missing NEMachServiceName in Info.plist") -} - -logger.debug("listening on machServiceName: \(serviceName)") - autoreleasepool { NEProvider.startSystemExtensionMode() } -let globalXPCListenerDelegate = AppXPCListener() -let xpcListener = NSXPCListener(machServiceName: serviceName) -xpcListener.delegate = globalXPCListenerDelegate -xpcListener.resume() - let globalHelperXPCSpeaker = HelperXPCSpeaker() dispatchMain() diff --git a/Coder-Desktop/VPNLib/XPC.swift b/Coder-Desktop/VPNLib/XPC.swift index baea7fe9..3ec3c266 100644 --- a/Coder-Desktop/VPNLib/XPC.swift +++ b/Coder-Desktop/VPNLib/XPC.swift @@ -1,17 +1,41 @@ import Foundation +// The Helper listens on two mach services, one for the GUI app +// and one for the system network extension. +// These must be kept in sync with `com.coder.Coder-Desktop.Helper.plist` +public let helperAppMachServiceName = "4399GN35BJ.com.coder.Coder-Desktop.HelperApp" +public let helperNEMachServiceName = "4399GN35BJ.com.coder.Coder-Desktop.HelperNE" + +// This is the XPC interface the Network Extension exposes to the Helper. @preconcurrency -@objc public protocol VPNXPCProtocol { - func getPeerState(with reply: @escaping (Data?) -> Void) - func ping(with reply: @escaping () -> Void) +@objc public protocol NEXPCInterface { + // diff is a serialized Vpn_NetworkSettingsRequest + func applyTunnelNetworkSettings(diff: Data, reply: @escaping () -> Void) + func cancelProvider(error: Error?, reply: @escaping () -> Void) +} + +// This is the XPC interface the GUI app exposes to the Helper. +@preconcurrency +@objc public protocol AppXPCInterface { + // diff is a serialized `Vpn_PeerUpdate` + func onPeerUpdate(_ diff: Data, reply: @escaping () -> Void) + func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?, reply: @escaping () -> Void) +} + +// This is the XPC interface the Helper exposes to the Network Extension. +@preconcurrency +@objc public protocol HelperNEXPCInterface { + // headers is a JSON `[HTTPHeader]` + func startDaemon(accessURL: URL, token: String, tun: FileHandle, headers: Data?, reply: @escaping (Error?) -> Void) + func stopDaemon(reply: @escaping (Error?) -> Void) } +// This is the XPC interface the Helper exposes to the GUI app. @preconcurrency -@objc public protocol VPNXPCClientCallbackProtocol { - // data is a serialized `Vpn_PeerUpdate` - func onPeerUpdate(_ data: Data) - func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?) - func removeQuarantine(path: String, reply: @escaping (Bool) -> Void) +@objc public protocol HelperAppXPCInterface { + func ping(reply: @escaping () -> Void) + // Data is a serialized `Vpn_PeerUpdate` + func getPeerState(with reply: @escaping (Data?) -> Void) } @objc public enum ProgressStage: Int, Sendable { @@ -36,3 +60,16 @@ import Foundation } } } + +public enum XPCError: Error { + case wrongProxyType + + var description: String { + switch self { + case .wrongProxyType: + "Wrong proxy type" + } + } + + var localizedDescription: String { description } +} diff --git a/Coder-Desktop/project.yml b/Coder-Desktop/project.yml index 166a1570..52056f5c 100644 --- a/Coder-Desktop/project.yml +++ b/Coder-Desktop/project.yml @@ -252,7 +252,6 @@ targets: platform: macOS sources: - path: VPN - - path: Coder-DesktopHelper/HelperXPCProtocol.swift entitlements: path: VPN/VPN.entitlements properties: @@ -272,7 +271,7 @@ targets: PRODUCT_MODULE_NAME: "$(PRODUCT_NAME:c99extidentifier)" PRODUCT_NAME: "$(PRODUCT_BUNDLE_IDENTIFIER)" SWIFT_EMIT_LOC_STRINGS: YES - SWIFT_OBJC_BRIDGING_HEADER: "VPN/com_coder_Coder_Desktop_VPN-Bridging-Header.h" + SWIFT_OBJC_BRIDGING_HEADER: "Coder-DesktopHelper/com_coder_Coder_Desktop_VPN-Bridging-Header.h" # `CODE_SIGN_*` are overriden during a release build CODE_SIGN_IDENTITY: "Apple Development" CODE_SIGN_STYLE: Automatic @@ -370,10 +369,19 @@ targets: type: tool platform: macOS sources: Coder-DesktopHelper + dependencies: + - target: VPNLib + embed: false # Loaded from SE bundle. settings: base: ENABLE_HARDENED_RUNTIME: YES + SWIFT_OBJC_BRIDGING_HEADER: "Coder-DesktopHelper/com_coder_Coder_Desktop_VPN-Bridging-Header.h" PRODUCT_BUNDLE_IDENTIFIER: "com.coder.Coder-Desktop.Helper" PRODUCT_MODULE_NAME: "$(PRODUCT_NAME:c99extidentifier)" PRODUCT_NAME: "$(PRODUCT_BUNDLE_IDENTIFIER)" - SKIP_INSTALL: YES \ No newline at end of file + SKIP_INSTALL: YES + LD_RUNPATH_SEARCH_PATHS: + # Load frameworks from the SE bundle. + - "@executable_path/../../Contents/Library/SystemExtensions/com.coder.Coder-Desktop.VPN.systemextension/Contents/Frameworks" + - "@executable_path/../Frameworks" + - "@loader_path/Frameworks" \ No newline at end of file From e96075ec3b9fd2882a21b1e39f1dda5c66b5482a Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 31 Jul 2025 13:54:35 +1000 Subject: [PATCH 2/2] renames --- ...terface.swift => AppHelperXPCClient.swift} | 44 +++---- .../Coder-Desktop/VPN/VPNService.swift | 2 +- .../Coder-Desktop/Views/LoginForm.swift | 4 +- .../HelperXPCListeners.swift | 115 +++++++++--------- .../Coder-DesktopHelper/Manager.swift | 12 +- Coder-Desktop/Coder-DesktopHelper/main.swift | 16 +-- ...CSpeaker.swift => NEHelperXPCClient.swift} | 48 ++++---- Coder-Desktop/VPN/PacketTunnelProvider.swift | 8 +- Coder-Desktop/VPN/main.swift | 2 +- Coder-Desktop/VPNLib/Download.swift | 4 +- 10 files changed, 127 insertions(+), 128 deletions(-) rename Coder-Desktop/Coder-Desktop/{XPCInterface.swift => AppHelperXPCClient.swift} (90%) rename Coder-Desktop/VPN/{HelperXPCSpeaker.swift => NEHelperXPCClient.swift} (95%) diff --git a/Coder-Desktop/Coder-Desktop/XPCInterface.swift b/Coder-Desktop/Coder-Desktop/AppHelperXPCClient.swift similarity index 90% rename from Coder-Desktop/Coder-Desktop/XPCInterface.swift rename to Coder-Desktop/Coder-Desktop/AppHelperXPCClient.swift index d6a54098..e79940dc 100644 --- a/Coder-Desktop/Coder-Desktop/XPCInterface.swift +++ b/Coder-Desktop/Coder-Desktop/AppHelperXPCClient.swift @@ -3,9 +3,10 @@ import NetworkExtension import os import VPNLib -@objc final class AppXPCListener: NSObject, AppXPCInterface, @unchecked Sendable { +// This is the client for the app to communicate with the privileged helper. +@objc final class HelperXPCClient: NSObject, @unchecked Sendable { private var svc: CoderVPNService - private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "AppXPCListener") + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperXPCClient") private var connection: NSXPCConnection? init(vpn: CoderVPNService) { @@ -41,25 +42,7 @@ import VPNLib return connection } - func onPeerUpdate(_ diff: Data, reply: @escaping () -> Void) { - let reply = CompletionWrapper(reply) - Task { @MainActor in - svc.onExtensionPeerUpdate(diff) - reply() - } - } - - func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?, reply: @escaping () -> Void) { - let reply = CompletionWrapper(reply) - Task { @MainActor in - svc.onProgress(stage: stage, downloadProgress: downloadProgress) - reply() - } - } -} - -// These methods are called to request updatess from the Helper. -extension AppXPCListener { + // Establishes a connection to the Helper, so it can send messages back. func ping() async throws { let conn = connect() return try await withCheckedThrowingContinuation { continuation in @@ -98,3 +81,22 @@ extension AppXPCListener { } } } + +// These methods by the Helper over XPC +extension HelperXPCClient: AppXPCInterface { + func onPeerUpdate(_ diff: Data, reply: @escaping () -> Void) { + let reply = CompletionWrapper(reply) + Task { @MainActor in + svc.onExtensionPeerUpdate(diff) + reply() + } + } + + func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?, reply: @escaping () -> Void) { + let reply = CompletionWrapper(reply) + Task { @MainActor in + svc.onProgress(stage: stage, downloadProgress: downloadProgress) + reply() + } + } +} diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift index 71e38884..abf2990e 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift @@ -54,7 +54,7 @@ enum VPNServiceError: Error, Equatable { @MainActor final class CoderVPNService: NSObject, VPNService { var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "vpn") - lazy var xpc: AppXPCListener = .init(vpn: self) + lazy var xpc: HelperXPCClient = .init(vpn: self) @Published var tunnelState: VPNServiceState = .disabled { didSet { diff --git a/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift b/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift index 798a4727..c2374f6a 100644 --- a/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift +++ b/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift @@ -90,7 +90,7 @@ struct LoginForm: View { return } // x.compare(y) is .orderedDescending if x > y - guard SignatureValidator.minimumCoderVersion.compare(semver, options: .numeric) != .orderedDescending else { + guard Validator.minimumCoderVersion.compare(semver, options: .numeric) != .orderedDescending else { loginError = .outdatedCoderVersion return } @@ -221,7 +221,7 @@ enum LoginError: Error { "Invalid URL" case .outdatedCoderVersion: """ - The Coder deployment must be version \(SignatureValidator.minimumCoderVersion) + The Coder deployment must be version \(Validator.minimumCoderVersion) or higher to use Coder Desktop. """ case let .failedAuth(err): diff --git a/Coder-Desktop/Coder-DesktopHelper/HelperXPCListeners.swift b/Coder-Desktop/Coder-DesktopHelper/HelperXPCListeners.swift index 6ed32336..ae955787 100644 --- a/Coder-Desktop/Coder-DesktopHelper/HelperXPCListeners.swift +++ b/Coder-Desktop/Coder-DesktopHelper/HelperXPCListeners.swift @@ -5,8 +5,8 @@ import VPNLib // This listener handles XPC connections from the Coder Desktop System Network // Extension (`com.coder.Coder-Desktop.VPN`). -class HelperNEXPCListener: NSObject, NSXPCListenerDelegate, HelperNEXPCInterface, @unchecked Sendable { - private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperNEXPCListener") +class HelperNEXPCServer: NSObject, NSXPCListenerDelegate, @unchecked Sendable { + private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperNEXPCServer") private var conns: [NSXPCConnection] = [] // Hold a reference to the tun file handle @@ -37,6 +37,43 @@ class HelperNEXPCListener: NSObject, NSXPCListenerDelegate, HelperNEXPCInterface return true } + func cancelProvider(error: Error?) async throws { + try await withCheckedThrowingContinuation { continuation in + guard let proxy = conns.last?.remoteObjectProxyWithErrorHandler({ err in + self.logger.error("failed to connect to HelperNEXPC \(err.localizedDescription, privacy: .public)") + continuation.resume(throwing: err) + }) as? NEXPCInterface else { + self.logger.error("failed to get proxy for HelperNEXPCInterface") + continuation.resume(throwing: XPCError.wrongProxyType) + return + } + proxy.cancelProvider(error: error) { + self.logger.info("provider cancelled") + continuation.resume() + } + } as Void + } + + func applyTunnelNetworkSettings(diff: Vpn_NetworkSettingsRequest) async throws { + let bytes = try diff.serializedData() + return try await withCheckedThrowingContinuation { continuation in + guard let proxy = conns.last?.remoteObjectProxyWithErrorHandler({ err in + self.logger.error("failed to connect to HelperNEXPC \(err.localizedDescription, privacy: .public)") + continuation.resume(throwing: err) + }) as? NEXPCInterface else { + self.logger.error("failed to get proxy for HelperNEXPCInterface") + continuation.resume(throwing: XPCError.wrongProxyType) + return + } + proxy.applyTunnelNetworkSettings(diff: bytes) { + self.logger.info("applied tunnel network setting") + continuation.resume() + } + } + } +} + +extension HelperNEXPCServer: HelperNEXPCInterface { func startDaemon( accessURL: URL, token: String, @@ -88,49 +125,10 @@ class HelperNEXPCListener: NSObject, NSXPCListenerDelegate, HelperNEXPCInterface } } -// These methods are called to send updates to the Coder Desktop System Network -// Extension. -extension HelperNEXPCListener { - func cancelProvider(error: Error?) async throws { - try await withCheckedThrowingContinuation { continuation in - guard let proxy = conns.last?.remoteObjectProxyWithErrorHandler({ err in - self.logger.error("failed to connect to HelperNEXPC \(err.localizedDescription, privacy: .public)") - continuation.resume(throwing: err) - }) as? NEXPCInterface else { - self.logger.error("failed to get proxy for HelperNEXPCInterface") - continuation.resume(throwing: XPCError.wrongProxyType) - return - } - proxy.cancelProvider(error: error) { - self.logger.info("provider cancelled") - continuation.resume() - } - } as Void - } - - func applyTunnelNetworkSettings(diff: Vpn_NetworkSettingsRequest) async throws { - let bytes = try diff.serializedData() - return try await withCheckedThrowingContinuation { continuation in - guard let proxy = conns.last?.remoteObjectProxyWithErrorHandler({ err in - self.logger.error("failed to connect to HelperNEXPC \(err.localizedDescription, privacy: .public)") - continuation.resume(throwing: err) - }) as? NEXPCInterface else { - self.logger.error("failed to get proxy for HelperNEXPCInterface") - continuation.resume(throwing: XPCError.wrongProxyType) - return - } - proxy.applyTunnelNetworkSettings(diff: bytes) { - self.logger.info("applied tunnel network setting") - continuation.resume() - } - } - } -} - // This listener handles XPC connections from the Coder Desktop App // (`com.coder.Coder-Desktop`). -class HelperAppXPCListener: NSObject, NSXPCListenerDelegate, HelperAppXPCInterface, @unchecked Sendable { - private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperAppXPCListener") +class HelperAppXPCServer: NSObject, NSXPCListenerDelegate, @unchecked Sendable { + private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperAppXPCServer") private var conns: [NSXPCConnection] = [] override init() { @@ -152,22 +150,6 @@ class HelperAppXPCListener: NSObject, NSXPCListenerDelegate, HelperAppXPCInterfa return true } - func getPeerState(with reply: @escaping (Data?) -> Void) { - logger.info("getPeerState called") - let reply = CallbackWrapper(reply) - Task { @MainActor in - let data = try? await globalManager?.getPeerState().serializedData() - reply(data) - } - } - - func ping(reply: @escaping () -> Void) { - reply() - } -} - -// These methods are called to send updates to the Coder Desktop App. -extension HelperAppXPCListener { func onPeerUpdate(update: Vpn_PeerUpdate) async throws { let bytes = try update.serializedData() return try await withCheckedThrowingContinuation { continuation in @@ -203,3 +185,18 @@ extension HelperAppXPCListener { } as Void } } + +extension HelperAppXPCServer: HelperAppXPCInterface { + func getPeerState(with reply: @escaping (Data?) -> Void) { + logger.info("getPeerState called") + let reply = CallbackWrapper(reply) + Task { @MainActor in + let data = try? await globalManager?.getPeerState().serializedData() + reply(data) + } + } + + func ping(reply: @escaping () -> Void) { + reply() + } +} diff --git a/Coder-Desktop/Coder-DesktopHelper/Manager.swift b/Coder-Desktop/Coder-DesktopHelper/Manager.swift index e2d47b8b..07a21ed9 100644 --- a/Coder-Desktop/Coder-DesktopHelper/Manager.swift +++ b/Coder-Desktop/Coder-DesktopHelper/Manager.swift @@ -56,7 +56,7 @@ actor Manager { throw .serverInfo("invalid version: \(buildInfo.version)") } do { - try SignatureValidator.validate(path: dest, expectedVersion: semver) + try Validator.validate(path: dest, expectedVersion: semver) } catch { throw .validation(error) } @@ -100,14 +100,14 @@ actor Manager { } catch { logger.error("tunnel read loop failed: \(error.localizedDescription, privacy: .public)") try await tunnelHandle.close() - try await NEXPCListenerDelegate.cancelProvider(error: + try await NEXPCServerDelegate.cancelProvider(error: makeNSError(suffix: "Manager", desc: "Tunnel read loop failed: \(error.localizedDescription)") ) return } logger.info("tunnel read loop exited") try await tunnelHandle.close() - try await NEXPCListenerDelegate.cancelProvider(error: nil) + try await NEXPCServerDelegate.cancelProvider(error: nil) } func handleMessage(_ msg: Vpn_TunnelMessage) { @@ -117,7 +117,7 @@ actor Manager { } switch msgType { case .peerUpdate: - Task { try? await appXPCListenerDelegate.onPeerUpdate(update: msg.peerUpdate) } + Task { try? await appXPCServerDelegate.onPeerUpdate(update: msg.peerUpdate) } case let .log(logMsg): writeVpnLog(logMsg) case .networkSettings, .start, .stop: @@ -133,7 +133,7 @@ actor Manager { switch msgType { case let .networkSettings(ns): do { - try await NEXPCListenerDelegate.applyTunnelNetworkSettings(diff: ns) + try await NEXPCServerDelegate.applyTunnelNetworkSettings(diff: ns) try? await rpc.sendReply(.with { resp in resp.networkSettings = .with { settings in settings.success = true @@ -227,7 +227,7 @@ actor Manager { } func pushProgress(stage: ProgressStage, downloadProgress: DownloadProgress? = nil) { - Task { try? await appXPCListenerDelegate.onProgress(stage: stage, downloadProgress: downloadProgress) } + Task { try? await appXPCServerDelegate.onProgress(stage: stage, downloadProgress: downloadProgress) } } struct ManagerConfig { diff --git a/Coder-Desktop/Coder-DesktopHelper/main.swift b/Coder-Desktop/Coder-DesktopHelper/main.swift index 9fc3879d..da777746 100644 --- a/Coder-Desktop/Coder-DesktopHelper/main.swift +++ b/Coder-Desktop/Coder-DesktopHelper/main.swift @@ -5,14 +5,14 @@ import VPNLib var globalManager: Manager? -let NEXPCListenerDelegate = HelperNEXPCListener() -let NEXPCListener = NSXPCListener(machServiceName: helperNEMachServiceName) -NEXPCListener.delegate = NEXPCListenerDelegate -NEXPCListener.resume() +let NEXPCServerDelegate = HelperNEXPCServer() +let NEXPCServer = NSXPCListener(machServiceName: helperNEMachServiceName) +NEXPCServer.delegate = NEXPCServerDelegate +NEXPCServer.resume() -let appXPCListenerDelegate = HelperAppXPCListener() -let appXPCListener = NSXPCListener(machServiceName: helperAppMachServiceName) -appXPCListener.delegate = appXPCListenerDelegate -appXPCListener.resume() +let appXPCServerDelegate = HelperAppXPCServer() +let appXPCServer = NSXPCListener(machServiceName: helperAppMachServiceName) +appXPCServer.delegate = appXPCServerDelegate +appXPCServer.resume() RunLoop.main.run() diff --git a/Coder-Desktop/VPN/HelperXPCSpeaker.swift b/Coder-Desktop/VPN/NEHelperXPCClient.swift similarity index 95% rename from Coder-Desktop/VPN/HelperXPCSpeaker.swift rename to Coder-Desktop/VPN/NEHelperXPCClient.swift index 0b5c6c45..b61cb581 100644 --- a/Coder-Desktop/VPN/HelperXPCSpeaker.swift +++ b/Coder-Desktop/VPN/NEHelperXPCClient.swift @@ -2,7 +2,7 @@ import Foundation import os import VPNLib -final class HelperXPCSpeaker: NEXPCInterface, @unchecked Sendable { +final class HelperXPCClient: @unchecked Sendable { var ptp: PacketTunnelProvider? private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperXPCSpeaker") private var connection: NSXPCConnection? @@ -34,29 +34,6 @@ final class HelperXPCSpeaker: NEXPCInterface, @unchecked Sendable { return connection } - func applyTunnelNetworkSettings(diff: Data, reply: @escaping () -> Void) { - let reply = CompletionWrapper(reply) - guard let diff = try? Vpn_NetworkSettingsRequest(serializedBytes: diff) else { - reply() - return - } - Task { - try? await ptp?.applyTunnelNetworkSettings(diff) - reply() - } - } - - func cancelProvider(error: Error?, reply: @escaping () -> Void) { - let reply = CompletionWrapper(reply) - Task { - ptp?.cancelTunnelWithError(error) - reply() - } - } -} - -// These methods are called to start and stop the daemon run by the Helper. -extension HelperXPCSpeaker { func startDaemon(accessURL: URL, token: String, tun: FileHandle, headers: Data?) async throws { let conn = connect() return try await withCheckedThrowingContinuation { continuation in @@ -103,3 +80,26 @@ extension HelperXPCSpeaker { } } } + +// These methods are called over XPC by the helper. +extension HelperXPCClient: NEXPCInterface { + func applyTunnelNetworkSettings(diff: Data, reply: @escaping () -> Void) { + let reply = CompletionWrapper(reply) + guard let diff = try? Vpn_NetworkSettingsRequest(serializedBytes: diff) else { + reply() + return + } + Task { + try? await ptp?.applyTunnelNetworkSettings(diff) + reply() + } + } + + func cancelProvider(error: Error?, reply: @escaping () -> Void) { + let reply = CompletionWrapper(reply) + Task { + ptp?.cancelTunnelWithError(error) + reply() + } + } +} diff --git a/Coder-Desktop/VPN/PacketTunnelProvider.swift b/Coder-Desktop/VPN/PacketTunnelProvider.swift index 6f54381a..a2d35597 100644 --- a/Coder-Desktop/VPN/PacketTunnelProvider.swift +++ b/Coder-Desktop/VPN/PacketTunnelProvider.swift @@ -46,7 +46,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { override func startTunnel( options _: [String: NSObject]? ) async throws { - globalHelperXPCSpeaker.ptp = self + globalHelperXPCClient.ptp = self guard let proto = protocolConfiguration as? NETunnelProviderProtocol, let baseAccessURL = proto.serverAddress else { @@ -64,7 +64,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { logger.error("startTunnel called with nil tunnelFileDescriptor") throw makeNSError(suffix: "PTP", desc: "Missing Tunnel File Descriptor") } - try await globalHelperXPCSpeaker.startDaemon( + try await globalHelperXPCClient.startDaemon( accessURL: .init(string: baseAccessURL)!, token: token, tun: FileHandle(fileDescriptor: tunFd), @@ -76,9 +76,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { with _: NEProviderStopReason ) async { logger.debug("stopping tunnel") - try? await globalHelperXPCSpeaker.stopDaemon() + try? await globalHelperXPCClient.stopDaemon() logger.info("tunnel stopped") - globalHelperXPCSpeaker.ptp = nil + globalHelperXPCClient.ptp = nil } override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) { diff --git a/Coder-Desktop/VPN/main.swift b/Coder-Desktop/VPN/main.swift index 3255f395..96533bc8 100644 --- a/Coder-Desktop/VPN/main.swift +++ b/Coder-Desktop/VPN/main.swift @@ -9,6 +9,6 @@ autoreleasepool { NEProvider.startSystemExtensionMode() } -let globalHelperXPCSpeaker = HelperXPCSpeaker() +let globalHelperXPCClient = HelperXPCClient() dispatchMain() diff --git a/Coder-Desktop/VPNLib/Download.swift b/Coder-Desktop/VPNLib/Download.swift index f6ffe5bc..055ca533 100644 --- a/Coder-Desktop/VPNLib/Download.swift +++ b/Coder-Desktop/VPNLib/Download.swift @@ -32,7 +32,7 @@ public enum ValidationError: Error { "Info.plist is not embedded within the dylib." case .belowMinimumCoderVersion: """ - The Coder deployment must be version \(SignatureValidator.minimumCoderVersion) + The Coder deployment must be version \(Validator.minimumCoderVersion) or higher to use Coder Desktop. """ } @@ -41,7 +41,7 @@ public enum ValidationError: Error { public var localizedDescription: String { description } } -public class SignatureValidator { +public class Validator { // Whilst older dylibs exist, this app assumes v2.20 or later. public static let minimumCoderVersion = "2.20.0"