Skip to content

chore: add network extension manager #18

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 5 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
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 {
Copy link
Member Author

Choose a reason for hiding this comment

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

I don't think this is worth unit testing, at least right now. We'd need to mock the PTP, the TunnelHandle, the validator, and eventually the XPC speaker, all for a relatively simple abstraction that's mostly error handling.

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