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: []