diff --git a/Coder Desktop/Coder Desktop/Coder_Desktop.entitlements b/Coder Desktop/Coder Desktop/Coder_Desktop.entitlements index 91f1361..7d90a16 100644 --- a/Coder Desktop/Coder Desktop/Coder_Desktop.entitlements +++ b/Coder Desktop/Coder Desktop/Coder_Desktop.entitlements @@ -10,6 +10,10 @@ com.apple.security.app-sandbox + com.apple.security.application-groups + + $(TeamIdentifierPrefix)com.coder.Coder-Desktop + com.apple.security.files.user-selected.read-only com.apple.security.network.client diff --git a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift index 4bec8d2..b952e98 100644 --- a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift +++ b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift @@ -49,8 +49,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminate(_: NSApplication) -> NSApplication.TerminateReply { Task { - await vpn.stop() - NSApp.reply(toApplicationShouldTerminate: true) + await vpn.quit() } return .terminateLater } diff --git a/Coder Desktop/Coder Desktop/Info.plist b/Coder Desktop/Coder Desktop/Info.plist new file mode 100644 index 0000000..8609906 --- /dev/null +++ b/Coder Desktop/Coder Desktop/Info.plist @@ -0,0 +1,11 @@ + + + + + NetworkExtension + + NEMachServiceName + $(TeamIdentifierPrefix)com.coder.Coder-Desktop.VPN + + + diff --git a/Coder Desktop/Coder Desktop/VPNService.swift b/Coder Desktop/Coder Desktop/VPNService.swift index 46c3cab..3506e10 100644 --- a/Coder Desktop/Coder Desktop/VPNService.swift +++ b/Coder Desktop/Coder Desktop/VPNService.swift @@ -1,6 +1,8 @@ import NetworkExtension import os import SwiftUI +import VPNLib +import VPNXPC @MainActor protocol VPNService: ObservableObject { @@ -43,6 +45,9 @@ 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) + var terminating = false + @Published var tunnelState: VPNServiceState = .disabled @Published var sysExtnState: SystemExtensionState = .uninstalled @Published var neState: NetworkExtensionState = .unconfigured @@ -71,46 +76,45 @@ final class CoderVPNService: NSObject, VPNService { } } - var startTask: Task? func start() async { - if await startTask?.value != nil { + switch tunnelState { + case .disabled, .failed: + break + default: return } - startTask = Task { - tunnelState = .connecting - await enableNetworkExtension() - // TODO: enable communication with the NetworkExtension to track state and agents. For - // now, just pretend it worked... - tunnelState = .connected - } - defer { startTask = nil } - await startTask?.value + // this ping is somewhat load bearing since it causes xpc to init + xpc.ping() + tunnelState = .connecting + await enableNetworkExtension() + logger.debug("network extension enabled") } - var stopTask: Task? func stop() async { - // Wait for a start operation to finish first - await startTask?.value - guard state == .connected else { return } - if await stopTask?.value != nil { - return - } - stopTask = Task { - tunnelState = .disconnecting - await disableNetworkExtension() + guard tunnelState == .connected else { return } + tunnelState = .disconnecting + await disableNetworkExtension() + logger.info("network extension stopped") + } - // TODO: determine when the NetworkExtension is completely disconnected - tunnelState = .disabled + // Instructs the service to stop the VPN and then quit once the stop event + // is read over XPC. + // MUST only be called from `NSApplicationDelegate.applicationShouldTerminate` + // MUST eventually call `NSApp.reply(toApplicationShouldTerminate: true)` + func quit() async { + guard tunnelState == .connected else { + NSApp.reply(toApplicationShouldTerminate: true) + return } - defer { stopTask = nil } - await stopTask?.value + terminating = true + await stop() } func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?) { Task { - if proto != nil { - await configureNetworkExtension(proto: proto!) + if let proto { + await configureNetworkExtension(proto: proto) // this just configures the VPN, it doesn't enable it tunnelState = .disabled } else { @@ -119,10 +123,39 @@ final class CoderVPNService: NSObject, VPNService { neState = .unconfigured tunnelState = .disabled } catch { - logger.error("failed to remoing network extension: \(error)") + logger.error("failed to remove network extension: \(error)") neState = .failed(error.localizedDescription) } } } } + + func onExtensionPeerUpdate(_ data: Data) { + // TODO: handle peer update + logger.info("network extension peer update") + do { + let msg = try Vpn_TunnelMessage(serializedBytes: data) + debugPrint(msg) + } catch { + logger.error("failed to decode peer update \(error)") + } + } + + func onExtensionStart() { + logger.info("network extension reported started") + tunnelState = .connected + } + + func onExtensionStop() { + logger.info("network extension reported stopped") + tunnelState = .disabled + if terminating { + NSApp.reply(toApplicationShouldTerminate: true) + } + } + + func onExtensionError(_ error: NSError) { + logger.error("network extension reported error: \(error)") + tunnelState = .failed(.internalError(error.localizedDescription)) + } } diff --git a/Coder Desktop/Coder Desktop/XPCInterface.swift b/Coder Desktop/Coder Desktop/XPCInterface.swift new file mode 100644 index 0000000..6c0861c --- /dev/null +++ b/Coder Desktop/Coder Desktop/XPCInterface.swift @@ -0,0 +1,70 @@ +import Foundation +import os +import VPNXPC + +@objc final class VPNXPCInterface: NSObject, VPNXPCClientCallbackProtocol, @unchecked Sendable { + private var svc: CoderVPNService + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VPNXPCInterface") + private let xpc: VPNXPCProtocol + + init(vpn: CoderVPNService) { + svc = vpn + + 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 + + super.init() + + xpcConn.exportedObject = self + xpcConn.invalidationHandler = { [logger] in + Task { @MainActor in + logger.error("XPC connection invalidated.") + } + } + xpcConn.interruptionHandler = { [logger] in + Task { @MainActor in + logger.error("XPC connection interrupted.") + } + } + xpcConn.resume() + } + + func ping() { + xpc.ping { + Task { @MainActor in + self.logger.info("Connected to NE over XPC") + } + } + } + + func onPeerUpdate(_ data: Data) { + Task { @MainActor in + svc.onExtensionPeerUpdate(data) + } + } + + func onStart() { + Task { @MainActor in + svc.onExtensionStart() + } + } + + func onStop() { + Task { @MainActor in + svc.onExtensionStop() + } + } + + func onError(_ err: NSError) { + Task { @MainActor in + svc.onExtensionError(err) + } + } +} diff --git a/Coder Desktop/VPN/Manager.swift b/Coder Desktop/VPN/Manager.swift index 9a3e35c..05a4241 100644 --- a/Coder Desktop/VPN/Manager.swift +++ b/Coder Desktop/VPN/Manager.swift @@ -2,6 +2,7 @@ import CoderSDK import NetworkExtension import os import VPNLib +import VPNXPC actor Manager { let ptp: PacketTunnelProvider @@ -10,7 +11,6 @@ actor Manager { let tunnelHandle: TunnelHandle let speaker: Speaker var readLoop: Task! - // TODO: XPC Speaker private let dest = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) .first!.appending(path: "coder-vpn.dylib") @@ -69,6 +69,7 @@ actor Manager { } catch { fatalError("openTunnelTask must only throw TunnelHandleError") } + readLoop = Task { try await run() } } @@ -85,12 +86,16 @@ actor Manager { } catch { logger.error("tunnel read loop failed: \(error.localizedDescription, privacy: .public)") try await tunnelHandle.close() - // TODO: Notify app over XPC + if let conn = globalXPCListenerDelegate.getActiveConnection() { + conn.onError(error as NSError) + } return } logger.info("tunnel read loop exited") try await tunnelHandle.close() - // TODO: Notify app over XPC + if let conn = globalXPCListenerDelegate.getActiveConnection() { + conn.onStop() + } } func handleMessage(_ msg: Vpn_TunnelMessage) { @@ -100,7 +105,14 @@ actor Manager { } switch msgType { case .peerUpdate: - {}() // TODO: Send over XPC + if let conn = globalXPCListenerDelegate.getActiveConnection() { + do { + let data = try msg.peerUpdate.serializedData() + conn.onPeerUpdate(data) + } catch { + logger.error("failed to send peer update to client: \(error)") + } + } case let .log(logMsg): writeVpnLog(logMsg) case .networkSettings, .start, .stop: @@ -138,36 +150,42 @@ actor Manager { func startVPN() async throws(ManagerError) { 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.apiToken = cfg.apiToken - req.coderURL = cfg.serverUrl.absoluteString - } - }) + resp = try await speaker.unaryRPC( + .with { msg in + msg.start = .with { req in + req.tunnelFileDescriptor = tunFd + req.apiToken = cfg.apiToken + req.coderURL = cfg.serverUrl.absoluteString + } + }) } catch { + logger.error("rpc failed \(error)") throw .failedRPC(error) } guard case let .start(startResp) = resp.msg else { + logger.error("incorrect response") throw .incorrectResponse(resp) } if !startResp.success { + logger.error("no success") throw .errorResponse(msg: startResp.errorMessage) } - // TODO: notify app over XPC + logger.info("startVPN done") } func stopVPN() async throws(ManagerError) { logger.info("sending stop rpc") let resp: Vpn_TunnelMessage do { - resp = try await speaker.unaryRPC(.with { msg in - msg.stop = .init() - }) + resp = try await speaker.unaryRPC( + .with { msg in + msg.stop = .init() + }) } catch { throw .failedRPC(error) } @@ -177,26 +195,25 @@ actor Manager { if !stopResp.success { throw .errorResponse(msg: stopResp.errorMessage) } - // TODO: notify app over XPC } - // TODO: Call via XPC // Retrieves the current state of all peers, // as required when starting the app whilst the network extension is already running - func getPeerInfo() async throws(ManagerError) { + func getPeerInfo() async throws(ManagerError) -> Vpn_PeerUpdate { logger.info("sending peer state request") let resp: Vpn_TunnelMessage do { - resp = try await speaker.unaryRPC(.with { msg in - msg.getPeerUpdate = .init() - }) + resp = try await speaker.unaryRPC( + .with { msg in + msg.getPeerUpdate = .init() + }) } catch { throw .failedRPC(error) } guard case .peerUpdate = resp.msg else { throw .incorrectResponse(resp) } - // TODO: pass to app over XPC + return resp.peerUpdate } } @@ -241,17 +258,18 @@ enum ManagerError: Error { } func writeVpnLog(_ log: Vpn_Log) { - let level: OSLogType = switch log.level { - case .info: .info - case .debug: .debug - // warn == error - case .warn: .error - case .error: .error - // critical == fatal == fault - case .critical: .fault - case .fatal: .fault - case .UNRECOGNIZED: .info - } + let level: OSLogType = + switch log.level { + case .info: .info + case .debug: .debug + // warn == error + case .warn: .error + case .error: .error + // critical == fatal == fault + case .critical: .fault + case .fatal: .fault + case .UNRECOGNIZED: .info + } let logger = Logger( subsystem: "\(Bundle.main.bundleIdentifier!).dylib", category: log.loggerNames.joined(separator: ".") diff --git a/Coder Desktop/VPN/PacketTunnelProvider.swift b/Coder Desktop/VPN/PacketTunnelProvider.swift index e548d8c..33020cd 100644 --- a/Coder Desktop/VPN/PacketTunnelProvider.swift +++ b/Coder Desktop/VPN/PacketTunnelProvider.swift @@ -1,6 +1,7 @@ import NetworkExtension import os import VPNLib +import VPNXPC /* From */ let CTLIOCGINFO: UInt = 0xC064_4E03 @@ -46,7 +47,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { override func startTunnel( options _: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void ) { - logger.debug("startTunnel called") + logger.info("startTunnel called") guard manager == nil else { logger.error("startTunnel called with non-nil Manager") completionHandler(PTPError.alreadyRunning) @@ -76,13 +77,24 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { apiToken: token, serverUrl: .init(string: baseAccessURL)! ) ) + globalXPCListenerDelegate.vpnXPCInterface.setManager(manager) logger.debug("starting vpn") try await manager!.startVPN() logger.info("vpn started") + if let conn = globalXPCListenerDelegate.getActiveConnection() { + conn.onStart() + } else { + logger.info("no active XPC connection") + } completionHandler(nil) } catch { - completionHandler(error) logger.error("error starting manager: \(error.description, privacy: .public)") + if let conn = globalXPCListenerDelegate.getActiveConnection() { + conn.onError(error as NSError) + } else { + logger.info("no active XPC connection") + } + completionHandler(error as NSError) } } } @@ -104,6 +116,12 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { } catch { logger.error("error stopping manager: \(error.description, privacy: .public)") } + if let conn = globalXPCListenerDelegate.getActiveConnection() { + conn.onStop() + } else { + logger.info("no active XPC connection") + } + globalXPCListenerDelegate.vpnXPCInterface.setManager(nil) completionHandler() } self.manager = nil diff --git a/Coder Desktop/VPN/XPCInterface.swift b/Coder Desktop/VPN/XPCInterface.swift new file mode 100644 index 0000000..3520fe8 --- /dev/null +++ b/Coder Desktop/VPN/XPCInterface.swift @@ -0,0 +1,32 @@ +import Foundation +import os.log +import VPNLib +import VPNXPC + +@objc final class XPCInterface: NSObject, VPNXPCProtocol, @unchecked Sendable { + private var manager: Manager? + private let managerLock = NSLock() + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VPNXPCInterface") + + func setManager(_ newManager: Manager?) { + managerLock.lock() + defer { managerLock.unlock() } + manager = newManager + } + + func getManager() -> Manager? { + managerLock.lock() + defer { managerLock.unlock() } + let m = manager + + return m + } + + func getPeerInfo(with reply: @escaping () -> Void) { + reply() + } + + func ping(with reply: @escaping () -> Void) { + reply() + } +} diff --git a/Coder Desktop/VPN/main.swift b/Coder Desktop/VPN/main.swift index 410c838..d350d8d 100644 --- a/Coder Desktop/VPN/main.swift +++ b/Coder Desktop/VPN/main.swift @@ -1,5 +1,56 @@ import Foundation import NetworkExtension +import os +import VPNXPC + +let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "provider") + +final class XPCListenerDelegate: NSObject, NSXPCListenerDelegate, @unchecked Sendable { + let vpnXPCInterface = XPCInterface() + var activeConnection: NSXPCConnection? + var connMutex: NSLock = .init() + + func getActiveConnection() -> VPNXPCClientCallbackProtocol? { + connMutex.lock() + defer { connMutex.unlock() } + + let client = activeConnection?.remoteObjectProxy as? VPNXPCClientCallbackProtocol + return client + } + + 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) + } + logger.info("new active connection") + setActiveConnection(newConnection) + + newConnection.resume() + return true + } +} + +guard + let netExt = Bundle.main.object(forInfoDictionaryKey: "NetworkExtension") as? [String: Any], + let serviceName = netExt["NEMachServiceName"] as? String +else { + fatalError("Missing NEMachServiceName in Info.plist") +} + +let globalXPCListenerDelegate = XPCListenerDelegate() +let xpcListener = NSXPCListener(machServiceName: serviceName) +xpcListener.delegate = globalXPCListenerDelegate +xpcListener.resume() autoreleasepool { NEProvider.startSystemExtensionMode() diff --git a/Coder Desktop/VPNXPC/Protocol.swift b/Coder Desktop/VPNXPC/Protocol.swift new file mode 100644 index 0000000..598a905 --- /dev/null +++ b/Coder Desktop/VPNXPC/Protocol.swift @@ -0,0 +1,16 @@ +import Foundation + +@preconcurrency +@objc public protocol VPNXPCProtocol { + func getPeerInfo(with reply: @escaping () -> Void) + func ping(with reply: @escaping () -> Void) +} + +@preconcurrency +@objc public protocol VPNXPCClientCallbackProtocol { + /// Called when the server has a status update to share + func onPeerUpdate(_ data: Data) + func onStart() + func onStop() + func onError(_ err: NSError) +} diff --git a/Coder Desktop/VPNXPC/VPNXPC.h b/Coder Desktop/VPNXPC/VPNXPC.h new file mode 100644 index 0000000..0fb9c0e --- /dev/null +++ b/Coder Desktop/VPNXPC/VPNXPC.h @@ -0,0 +1,11 @@ +#import + +//! Project version number for VPNXPC. +FOUNDATION_EXPORT double VPNXPCVersionNumber; + +//! Project version string for VPNXPC. +FOUNDATION_EXPORT const unsigned char VPNXPCVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/Coder Desktop/project.yml b/Coder Desktop/project.yml index 67bba40..2c23c88 100644 --- a/Coder Desktop/project.yml +++ b/Coder Desktop/project.yml @@ -119,6 +119,8 @@ targets: com.apple.security.app-sandbox: true com.apple.security.files.user-selected.read-only: true com.apple.security.network.client: true + com.apple.security.application-groups: + - $(TeamIdentifierPrefix)com.coder.Coder-Desktop settings: base: ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon # Sets the app icon to "AppIcon". @@ -144,6 +146,8 @@ targets: dependencies: - target: CoderSDK embed: true + - target: VPNXPC + embed: true - target: VPN embed: without-signing # Embed without signing. - package: FluidMenuBarExtra @@ -216,6 +220,8 @@ targets: embed: true - target: CoderSDK embed: true + - target: VPNXPC + embed: true - sdk: NetworkExtension.framework VPNLib: @@ -294,3 +300,19 @@ targets: base: TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Coder Desktop.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Coder Desktop" PRODUCT_BUNDLE_IDENTIFIER: com.coder.Coder-Desktop.CoderSDKTests + + VPNXPC: + type: framework + platform: macOS + sources: + - path: VPNXPC + settings: + base: + INFOPLIST_KEY_NSHumanReadableCopyright: "" + PRODUCT_NAME: "$(TARGET_NAME:c99extidentifier)" + SWIFT_EMIT_LOC_STRINGS: YES + GENERATE_INFOPLIST_FILE: YES + DYLIB_COMPATIBILITY_VERSION: 1 + DYLIB_CURRENT_VERSION: 1 + DYLIB_INSTALL_NAME_BASE: "@rpath" + dependencies: []