Skip to content

Commit

Permalink
chore: add network extension manager
Browse files Browse the repository at this point in the history
  • Loading branch information
ethanndickson committed Jan 10, 2025
1 parent 4d0b3da commit 7e24349
Show file tree
Hide file tree
Showing 10 changed files with 406 additions and 39 deletions.
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 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 throws(ValidationError) {
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 throws(HandshakeError) {
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(apiToken: String, server: URL) 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 = apiToken
req.coderURL = server.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
60 changes: 60 additions & 0 deletions Coder Desktop/VPNLib/Convert.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import NetworkExtension
import os

// swiftlint:disable function_body_length
public func convertNetworkSettingsRequest(_ req: Vpn_NetworkSettingsRequest) -> NEPacketTunnelNetworkSettings {
let networkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: req.tunnelRemoteAddress)
networkSettings.tunnelOverheadBytes = NSNumber(value: req.tunnelOverheadBytes)
networkSettings.mtu = NSNumber(value: req.mtu)

if req.hasDnsSettings {
let dnsSettings = NEDNSSettings(servers: req.dnsSettings.servers)
dnsSettings.searchDomains = req.dnsSettings.searchDomains
dnsSettings.domainName = req.dnsSettings.domainName
dnsSettings.matchDomains = req.dnsSettings.matchDomains
dnsSettings.matchDomainsNoSearch = req.dnsSettings.matchDomainsNoSearch
networkSettings.dnsSettings = dnsSettings
}

if req.hasIpv4Settings {
let ipv4Settings = NEIPv4Settings(addresses: req.ipv4Settings.addrs, subnetMasks: req.ipv4Settings.subnetMasks)
ipv4Settings.router = req.ipv4Settings.router
ipv4Settings.includedRoutes = req.ipv4Settings.includedRoutes.map {
let route = NEIPv4Route(destinationAddress: $0.destination, subnetMask: $0.mask)
route.gatewayAddress = $0.router
return route
}
ipv4Settings.excludedRoutes = req.ipv4Settings.excludedRoutes.map {
let route = NEIPv4Route(destinationAddress: $0.destination, subnetMask: $0.mask)
route.gatewayAddress = $0.router
return route
}
networkSettings.ipv4Settings = ipv4Settings
}

if req.hasIpv6Settings {
let ipv6Settings = NEIPv6Settings(
addresses: req.ipv6Settings.addrs,
networkPrefixLengths: req.ipv6Settings.prefixLengths.map { NSNumber(value: $0)
}
)
ipv6Settings.includedRoutes = req.ipv6Settings.includedRoutes.map {
let route = NEIPv6Route(
destinationAddress: $0.destination,
networkPrefixLength: NSNumber(value: $0.prefixLength)
)
route.gatewayAddress = $0.router
return route
}
ipv6Settings.excludedRoutes = req.ipv6Settings.excludedRoutes.map {
let route = NEIPv6Route(
destinationAddress: $0.destination,
networkPrefixLength: NSNumber(value: $0.prefixLength)
)
route.gatewayAddress = $0.router
return route
}
networkSettings.ipv6Settings = ipv6Settings
}
return networkSettings
}
2 changes: 1 addition & 1 deletion Coder Desktop/VPNLib/Receiver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ actor Receiver<RecvMsg: Message> {
/// On read or decoding error, it logs and closes the stream.
func messages() throws(ReceiveError) -> AsyncStream<RecvMsg> {
if running {
throw ReceiveError.alreadyRunning
throw .alreadyRunning
}
running = true
return AsyncStream(
Expand Down
Loading

0 comments on commit 7e24349

Please sign in to comment.