Skip to content

feat: add XPC communication to Network Extension #29

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Jan 30, 2025
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Coder Desktop/Coder Desktop/Coder_Desktop.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>$(TeamIdentifierPrefix)com.coder.Coder-Desktop</string>
</array>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.client</key>
Expand Down
11 changes: 11 additions & 0 deletions Coder Desktop/Coder Desktop/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NetworkExtension</key>
<dict>
<key>NEMachServiceName</key>
<string>$(TeamIdentifierPrefix)com.coder.Coder-Desktop.VPN</string>
</dict>
</dict>
</plist>
40 changes: 34 additions & 6 deletions Coder Desktop/Coder Desktop/VPNService.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import NetworkExtension
import os
import SwiftUI
import VPNLib
import VPNXPC

@MainActor
protocol VPNService: ObservableObject {
Expand Down Expand Up @@ -43,6 +45,9 @@ enum VPNServiceError: Error, Equatable {
@MainActor
final class CoderVPNService: NSObject, VPNService {
var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "vpn")
// TODO: better init maybe? kinda wonky
lazy var xpc = VPNXPCInterface(vpn: self)
Copy link
Member

@ethanndickson ethanndickson Jan 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is fine, since it's a chicken & egg problem. The alternative is to use an optional, probably with ! - lazy lets us skip the unwrap.


@Published var tunnelState: VPNServiceState = .disabled
@Published var sysExtnState: SystemExtensionState = .uninstalled
@Published var neState: NetworkExtensionState = .unconfigured
Expand Down Expand Up @@ -76,13 +81,12 @@ final class CoderVPNService: NSObject, VPNService {
if await startTask?.value != nil {
return
}
// this ping is somewhat load bearing since it causes xpc to init
xpc.ping()
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
logger.debug("network extension enabled")
}
defer { startTask = nil }
await startTask?.value
Expand All @@ -99,8 +103,7 @@ final class CoderVPNService: NSObject, VPNService {
stopTask = Task {
tunnelState = .disconnecting
await disableNetworkExtension()

// TODO: determine when the NetworkExtension is completely disconnected
logger.info("network extension stopped")
tunnelState = .disabled
}
defer { stopTask = nil }
Expand All @@ -125,4 +128,29 @@ final class CoderVPNService: NSObject, VPNService {
}
}
}

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
}

func onExtensionError(_ error: NSError) {
logger.info("network extension reported error: \(error)")
}
}
76 changes: 76 additions & 0 deletions Coder Desktop/Coder Desktop/XPCInterface.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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 = { [weak self] in
guard let self else { return }
Task { @MainActor in
self.logger.error("XPC connection invalidated.")
}
}
xpcConn.interruptionHandler = { [weak self] in
guard let self else { return }
Task { @MainActor in
self.logger.error("XPC connection interrupted.")
}
}
xpcConn.resume()

xpc.ping {
print("Got response from XPC")
}
}

func ping() {
xpc.ping {
Task { @MainActor in
print("Got response from 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)
}
}
}
84 changes: 51 additions & 33 deletions Coder Desktop/VPN/Manager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import CoderSDK
import NetworkExtension
import os
import VPNLib
import VPNXPC

actor Manager {
let ptp: PacketTunnelProvider
Expand All @@ -10,7 +11,6 @@ actor Manager {
let tunnelHandle: TunnelHandle
let speaker: Speaker<Vpn_ManagerMessage, Vpn_TunnelMessage>
var readLoop: Task<Void, any Error>!
// TODO: XPC Speaker

private let dest = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
.first!.appending(path: "coder-vpn.dylib")
Expand Down Expand Up @@ -69,6 +69,7 @@ actor Manager {
} catch {
fatalError("openTunnelTask must only throw TunnelHandleError")
}

readLoop = Task { try await run() }
}

Expand All @@ -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) {
Expand All @@ -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:
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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: ".")
Expand Down
Loading
Loading