Skip to content

Commit

Permalink
chore: add network extension manager (#18)
Browse files Browse the repository at this point in the history
Relates to #2.

Adds the `Manager` abstraction that:
- Downloads the dylib
- Calls the `SignatureValidator` on a downloaded dylib
- Passes network settings to `NEPacketTunnelProvider`
- Owns the `TunnelHandle`
- Reads and writes to the `Speaker`

Eventually, it'll act as the middleman between the tunnel Speaker and the XPC speaker.
  • Loading branch information
ethanndickson authored Jan 14, 2025
1 parent 161e5c2 commit 46c2c09
Show file tree
Hide file tree
Showing 12 changed files with 415 additions and 48 deletions.
12 changes: 6 additions & 6 deletions Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -832,7 +832,7 @@
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 4399GN35BJ;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.coder.Coder-DesktopTests";
PRODUCT_NAME = "$(TARGET_NAME)";
Expand All @@ -851,7 +851,7 @@
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 4399GN35BJ;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.coder.Coder-DesktopTests";
PRODUCT_NAME = "$(TARGET_NAME)";
Expand All @@ -869,7 +869,7 @@
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 4399GN35BJ;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.coder.Coder-DesktopUITests";
PRODUCT_NAME = "$(TARGET_NAME)";
Expand All @@ -887,7 +887,7 @@
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 4399GN35BJ;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.coder.Coder-DesktopUITests";
PRODUCT_NAME = "$(TARGET_NAME)";
Expand Down Expand Up @@ -1038,7 +1038,7 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 4399GN35BJ;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.coder.Coder-Desktop.VPNLibTests";
PRODUCT_NAME = "$(TARGET_NAME)";
Expand All @@ -1055,7 +1055,7 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 4399GN35BJ;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.coder.Coder-Desktop.VPNLibTests";
PRODUCT_NAME = "$(TARGET_NAME)";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ struct PreviewClient: Client {
roles: []
)
} catch {
throw ClientError.reqError(AFError.explicitlyCancelled)
throw .reqError(.explicitlyCancelled)
}
}
}
8 changes: 4 additions & 4 deletions Coder Desktop/Coder Desktop/SDK/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ struct CoderClient: Client {
case let .success(data):
return HTTPResponse(resp: out.response!, data: data, req: out.request)
case let .failure(error):
throw ClientError.reqError(error)
throw .reqError(error)
}
}

Expand All @@ -58,7 +58,7 @@ struct CoderClient: Client {
case let .success(data):
return HTTPResponse(resp: out.response!, data: data, req: out.request)
case let .failure(error):
throw ClientError.reqError(error)
throw .reqError(error)
}
}

Expand All @@ -71,9 +71,9 @@ struct CoderClient: Client {
method: resp.req?.httpMethod,
url: resp.req?.url
)
return ClientError.apiError(out)
return .apiError(out)
} catch {
return ClientError.unexpectedResponse(resp.data[...1024])
return .unexpectedResponse(resp.data[...1024])
}
}

Expand Down
2 changes: 1 addition & 1 deletion Coder Desktop/Coder Desktop/SDK/User.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ extension CoderClient {
do {
return try CoderClient.decoder.decode(User.self, from: res.data)
} catch {
throw ClientError.unexpectedResponse(res.data[...1024])
throw .unexpectedResponse(res.data[...1024])
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion Coder Desktop/Coder Desktop/Views/LoginForm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ struct LoginForm<C: Client, S: Session>: View {
loading = true
defer { loading = false }
let client = C(url: url, token: sessionToken)
do throws(ClientError) {
do {
_ = try await client.user("me")
} catch {
loginError = .failedAuth(error)
Expand Down
2 changes: 1 addition & 1 deletion Coder Desktop/Coder DesktopTests/Util.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ struct MockClient: Client {
struct MockErrorClient: Client {
init(url _: URL, token _: String?) {}
func user(_: String) async throws(ClientError) -> Coder_Desktop.User {
throw ClientError.reqError(.explicitlyCancelled)
throw .reqError(.explicitlyCancelled)
}
}

Expand Down
193 changes: 190 additions & 3 deletions Coder Desktop/VPN/Manager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,203 @@ import VPNLib

actor Manager {
let ptp: PacketTunnelProvider
let cfg: ManagerConfig

var tunnelHandle: TunnelHandle?
var speaker: Speaker<Vpn_ManagerMessage, Vpn_TunnelMessage>?
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")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "manager")

init(with: PacketTunnelProvider) {
init(with: PacketTunnelProvider, cfg: ManagerConfig) async throws(ManagerError) {
ptp = with
self.cfg = cfg
#if arch(arm64)
let dylibPath = cfg.serverUrl.appending(path: "bin/coder-vpn-arm64.dylib")
#elseif arch(x86_64)
let dylibPath = cfg.serverUrl.appending(path: "bin/coder-vpn-amd64.dylib")
#else
fatalError("unknown architecture")
#endif
do {
try await download(src: dylibPath, dest: dest)
} catch {
throw .download(error)
}
do {
try SignatureValidator.validate(path: dest)
} catch {
throw .validation(error)
}
do {
try tunnelHandle = TunnelHandle(dylibPath: dest)
} catch {
throw .tunnelSetup(error)
}
speaker = await Speaker<Vpn_ManagerMessage, Vpn_TunnelMessage>(
writeFD: tunnelHandle.writeHandle,
readFD: tunnelHandle.readHandle
)
do {
try await speaker.handshake()
} catch {
throw .handshake(error)
}
readLoop = Task { try await run() }
}

func run() async throws {
do {
for try await m in speaker {
switch m {
case let .message(msg):
handleMessage(msg)
case let .RPC(rpc):
handleRPC(rpc)
}
}
} catch {
logger.error("tunnel read loop failed: \(error)")
try await tunnelHandle.close()
// TODO: Notify app over XPC
return
}
logger.info("tunnel read loop exited")
try await tunnelHandle.close()
// TODO: Notify app over XPC
}

func handleMessage(_ msg: Vpn_TunnelMessage) {
guard let msgType = msg.msg else {
logger.critical("received message with no type")
return
}
switch msgType {
case .peerUpdate:
{}() // TODO: Send over XPC
case let .log(logMsg):
writeVpnLog(logMsg)
case .networkSettings, .start, .stop:
logger.critical("received unexpected message: `\(String(describing: msgType))`")
}
}

func handleRPC(_ rpc: RPCRequest<Vpn_ManagerMessage, Vpn_TunnelMessage>) {
guard let msgType = rpc.msg.msg else {
logger.critical("received rpc with no type")
return
}
switch msgType {
case let .networkSettings(ns):
let neSettings = convertNetworkSettingsRequest(ns)
ptp.setTunnelNetworkSettings(neSettings)
case .log, .peerUpdate, .start, .stop:
logger.critical("received unexpected rpc: `\(String(describing: msgType))`")
}
}

// TODO: Call via XPC
func startVPN() async throws(ManagerError) {
logger.info("sending start rpc")
guard let tunFd = ptp.tunnelFileDescriptor else {
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
}
})
} catch {
throw .failedRPC(error)
}
guard case let .start(startResp) = resp.msg else {
throw .incorrectResponse(resp)
}
if !startResp.success {
throw .errorResponse(msg: startResp.errorMessage)
}
// TODO: notify app over XPC
}

// TODO: Call via XPC
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()
})
} catch {
throw .failedRPC(error)
}
guard case let .stop(stopResp) = resp.msg else {
throw .incorrectResponse(resp)
}
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) {
logger.info("sending peer state request")
let resp: Vpn_TunnelMessage
do {
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
}
}

public struct ManagerConfig {
let apiToken: String
let serverUrl: URL
}

enum ManagerError: Error {
case download(DownloadError)
case tunnelSetup(TunnelHandleError)
case handshake(HandshakeError)
case validation(ValidationError)
case incorrectResponse(Vpn_TunnelMessage)
case failedRPC(any Error)
case errorResponse(msg: String)
case noTunnelFileDescriptor
}

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 logger = Logger(
subsystem: "\(Bundle.main.bundleIdentifier!).dylib",
category: log.loggerNames.joined(separator: ".")
)
let fields = log.fields.map { "\($0.name): \($0.value)" }.joined(separator: ", ")
logger.log(level: level, "\(log.message): \(fields)")
}
12 changes: 9 additions & 3 deletions Coder Desktop/VPN/PacketTunnelProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import os
let CTLIOCGINFO: UInt = 0xC064_4E03

class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "network-extension")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "packet-tunnel-provider")
private var manager: Manager?

private var tunnelFileDescriptor: Int32? {
public var tunnelFileDescriptor: Int32? {
var ctlInfo = ctl_info()
withUnsafeMutablePointer(to: &ctlInfo.ctl_name) {
$0.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size(ofValue: $0.pointee)) {
Expand Down Expand Up @@ -46,7 +46,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
completionHandler(nil)
return
}
manager = Manager(with: self)
Task {
// TODO: Retrieve access URL & Token via Keychain
manager = try await Manager(
with: self,
cfg: .init(apiToken: "fake-token", serverUrl: .init(string: "https://dev.coder.com")!)
)
}
completionHandler(nil)
}

Expand Down
Loading

0 comments on commit 46c2c09

Please sign in to comment.