Skip to content

Commit 50b6a23

Browse files
committed
chore: add network extension manager
1 parent ae5b3e2 commit 50b6a23

16 files changed

+458
-80
lines changed

Diff for: Coder Desktop/.swiftlint.yml

+2
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ type_name:
88
identifier_name:
99
allowed_symbols: "_"
1010
min_length: 1
11+
cyclomatic_complexity:
12+
warning: 15

Diff for: Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ struct PreviewClient: Client {
2323
roles: []
2424
)
2525
} catch {
26-
throw ClientError.reqError(AFError.explicitlyCancelled)
26+
throw .reqError(.explicitlyCancelled)
2727
}
2828
}
2929
}

Diff for: Coder Desktop/Coder Desktop/SDK/Client.swift

+4-4
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ struct CoderClient: Client {
3939
case let .success(data):
4040
return HTTPResponse(resp: out.response!, data: data, req: out.request)
4141
case let .failure(error):
42-
throw ClientError.reqError(error)
42+
throw .reqError(error)
4343
}
4444
}
4545

@@ -58,7 +58,7 @@ struct CoderClient: Client {
5858
case let .success(data):
5959
return HTTPResponse(resp: out.response!, data: data, req: out.request)
6060
case let .failure(error):
61-
throw ClientError.reqError(error)
61+
throw .reqError(error)
6262
}
6363
}
6464

@@ -71,9 +71,9 @@ struct CoderClient: Client {
7171
method: resp.req?.httpMethod,
7272
url: resp.req?.url
7373
)
74-
return ClientError.apiError(out)
74+
return .apiError(out)
7575
} catch {
76-
return ClientError.unexpectedResponse(resp.data[...1024])
76+
return .unexpectedResponse(resp.data[...1024])
7777
}
7878
}
7979

Diff for: Coder Desktop/Coder Desktop/SDK/User.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ extension CoderClient {
99
do {
1010
return try CoderClient.decoder.decode(User.self, from: res.data)
1111
} catch {
12-
throw ClientError.unexpectedResponse(res.data[...1024])
12+
throw .unexpectedResponse(res.data[...1024])
1313
}
1414
}
1515
}

Diff for: Coder Desktop/Coder DesktopTests/AgentsTests.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ struct AgentsTests {
3434
func agentsWhenVPNOff() throws {
3535
vpn.state = .disabled
3636

37-
#expect(throws: (any Error).self) {
37+
#expect(throws: (Error).self) {
3838
_ = try view.inspect().find(ViewType.ForEach.self)
3939
}
4040
}
@@ -80,7 +80,7 @@ struct AgentsTests {
8080
vpn.state = .connected
8181
vpn.agents = createMockAgents(count: 3)
8282

83-
#expect(throws: (any Error).self) {
83+
#expect(throws: (Error).self) {
8484
_ = try view.inspect().find(ViewType.Toggle.self)
8585
}
8686
}

Diff for: Coder Desktop/Coder DesktopTests/Util.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ struct MockClient: Client {
6868
struct MockErrorClient: Client {
6969
init(url _: URL, token _: String?) {}
7070
func user(_: String) async throws(ClientError) -> Coder_Desktop.User {
71-
throw ClientError.reqError(.explicitlyCancelled)
71+
throw .reqError(.explicitlyCancelled)
7272
}
7373
}
7474

Diff for: Coder Desktop/Coder DesktopTests/VPNStateTests.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ struct VPNStateTests {
7171

7272
try await ViewHosting.host(view.environmentObject(vpn)) {
7373
try await sut.inspection.inspect { view in
74-
#expect(throws: (any Error).self) {
74+
#expect(throws: (Error).self) {
7575
_ = try view.find(ViewType.Text.self)
7676
}
7777
}

Diff for: Coder Desktop/VPN/Manager.swift

+172-3
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,186 @@ import VPNLib
55
actor Manager {
66
let ptp: PacketTunnelProvider
77
let downloader: Downloader
8+
let cfg: ManagerConfig
89

9-
var tunnelHandle: TunnelHandle?
10-
var speaker: Speaker<Vpn_ManagerMessage, Vpn_TunnelMessage>?
10+
let tunnelHandle: TunnelHandle
11+
let speaker: Speaker<Vpn_ManagerMessage, Vpn_TunnelMessage>
12+
var readLoop: Task<Void, Error>!
1113
// TODO: XPC Speaker
1214

1315
private let dest = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
1416
.first!.appending(path: "coder-vpn.dylib")
1517
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "manager")
1618

17-
init(with: PacketTunnelProvider) {
19+
init(with: PacketTunnelProvider, cfg: ManagerConfig) async throws(ManagerError) {
1820
ptp = with
1921
downloader = Downloader()
22+
self.cfg = cfg
23+
#if arch(arm64)
24+
let dylibPath = cfg.serverUrl.appending(path: "bin/coder-vpn-arm64.dylib")
25+
#elseif arch(x86_64)
26+
let dylibPath = cfg.serverUrl.appending(path: "bin/coder-vpn-amd64.dylib")
27+
#else
28+
fatalError("unknown architecture")
29+
#endif
30+
do {
31+
try await downloader.download(src: dylibPath, dest: dest)
32+
} catch {
33+
throw .download(error)
34+
}
35+
do {
36+
try tunnelHandle = TunnelHandle(dylibPath: dest)
37+
} catch {
38+
throw .tunnelSetup(error)
39+
}
40+
speaker = await Speaker<Vpn_ManagerMessage, Vpn_TunnelMessage>(
41+
writeFD: tunnelHandle.writeHandle,
42+
readFD: tunnelHandle.readHandle
43+
)
44+
do throws(HandshakeError) {
45+
try await speaker.handshake()
46+
} catch {
47+
throw .handshake(error)
48+
}
49+
readLoop = Task {
50+
do {
51+
for try await m in speaker {
52+
switch m {
53+
case let .message(msg):
54+
handleMessage(msg)
55+
case let .RPC(rpc):
56+
handleRPC(rpc)
57+
}
58+
}
59+
} catch {
60+
logger.error("tunnel read loop failed: \(error)")
61+
try? await tunnelHandle.close()
62+
// TODO: Notify app over XPC
63+
}
64+
}
2065
}
66+
67+
func handleMessage(_ msg: Vpn_TunnelMessage) {
68+
guard let msgType = msg.msg else {
69+
logger.critical("received message with no type")
70+
return
71+
}
72+
switch msgType {
73+
case .peerUpdate:
74+
{}() // TODO: Send over XPC
75+
case let .log(logMsg):
76+
writeVpnLog(logMsg)
77+
case .networkSettings, .start, .stop:
78+
logger.critical("received unexpected message: `\(String(describing: msgType))`")
79+
}
80+
}
81+
82+
func handleRPC(_ rpc: RPCRequest<Vpn_ManagerMessage, Vpn_TunnelMessage>) {
83+
guard let msgType = rpc.msg.msg else {
84+
logger.critical("received rpc with no type")
85+
return
86+
}
87+
switch msgType {
88+
case let .networkSettings(ns):
89+
let neSettings = convertNetworkSettingsRequest(ns)
90+
ptp.setTunnelNetworkSettings(neSettings)
91+
case .log, .peerUpdate, .start, .stop:
92+
logger.critical("received unexpected rpc: `\(String(describing: msgType))`")
93+
}
94+
}
95+
96+
// TODO: Call via XPC
97+
func startVPN(apiToken: String, server: URL) async throws(ManagerError) {
98+
let resp: Vpn_TunnelMessage
99+
do {
100+
resp = try await speaker.unaryRPC(.with { msg in
101+
msg.start = .with { req in
102+
// TODO: handle nil FD
103+
req.tunnelFileDescriptor = ptp.tunnelFileDescriptor!
104+
req.apiToken = apiToken
105+
req.coderURL = server.absoluteString
106+
}
107+
})
108+
} catch {
109+
throw .failedRPC(error)
110+
}
111+
guard case let .start(startResp) = resp.msg else {
112+
throw .incorrectResponse(resp)
113+
}
114+
if !startResp.success {
115+
throw .errorResponse(msg: startResp.errorMessage)
116+
}
117+
// TODO: notify app over XPC
118+
}
119+
120+
// TODO: Call via XPC
121+
func stopVPN() async throws(ManagerError) {
122+
let resp: Vpn_TunnelMessage
123+
do {
124+
resp = try await speaker.unaryRPC(.with { msg in
125+
msg.stop = .init()
126+
})
127+
} catch {
128+
throw .failedRPC(error)
129+
}
130+
guard case let .stop(stopResp) = resp.msg else {
131+
throw .incorrectResponse(resp)
132+
}
133+
if !stopResp.success {
134+
throw .errorResponse(msg: stopResp.errorMessage)
135+
}
136+
// TODO: notify app over XPC
137+
}
138+
139+
// TODO: Call via XPC
140+
// Retrieves the current state of all peers,
141+
// as required when starting the app whilst the network extension is already running
142+
func getPeerInfo() async throws(ManagerError) {
143+
let resp: Vpn_TunnelMessage
144+
do {
145+
resp = try await speaker.unaryRPC(.with { msg in
146+
msg.getPeerUpdate = .init()
147+
})
148+
} catch {
149+
throw .failedRPC(error)
150+
}
151+
guard case .peerUpdate = resp.msg else {
152+
throw .incorrectResponse(resp)
153+
}
154+
// TODO: pass to app over XPC
155+
}
156+
}
157+
158+
public struct ManagerConfig {
159+
let apiToken: String
160+
let serverUrl: URL
161+
}
162+
163+
enum ManagerError: Error {
164+
case download(DownloadError)
165+
case tunnelSetup(TunnelHandleError)
166+
case handshake(HandshakeError)
167+
case incorrectResponse(Vpn_TunnelMessage)
168+
case failedRPC(Error)
169+
case errorResponse(msg: String)
170+
}
171+
172+
func writeVpnLog(_ log: Vpn_Log) {
173+
let level: OSLogType = switch log.level {
174+
case .info: .info
175+
case .debug: .debug
176+
// warn == error
177+
case .warn: .error
178+
case .error: .error
179+
// critical == fatal == fault
180+
case .critical: .fault
181+
case .fatal: .fault
182+
case .UNRECOGNIZED: .info
183+
}
184+
let logger = Logger(
185+
subsystem: "\(Bundle.main.bundleIdentifier!).dylib",
186+
category: log.loggerNames.joined(separator: ".")
187+
)
188+
let fields = log.fields.map { "\($0.name): \($0.value)" }.joined(separator: ", ")
189+
logger.log(level: level, "\(log.message): \(fields)")
21190
}

Diff for: Coder Desktop/VPN/PacketTunnelProvider.swift

+6-3
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import os
55
let CTLIOCGINFO: UInt = 0xC064_4E03
66

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

11-
private var tunnelFileDescriptor: Int32? {
11+
public var tunnelFileDescriptor: Int32? {
1212
var ctlInfo = ctl_info()
1313
withUnsafeMutablePointer(to: &ctlInfo.ctl_name) {
1414
$0.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size(ofValue: $0.pointee)) {
@@ -46,7 +46,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
4646
completionHandler(nil)
4747
return
4848
}
49-
manager = Manager(with: self)
49+
Task {
50+
// TODO: Receive access URL & Token via Keychain?
51+
manager = try await Manager(with: self, cfg: .init(apiToken: "fake-token", serverUrl: .init(string: "https://dev.coder.com")!))
52+
}
5053
completionHandler(nil)
5154
}
5255

Diff for: Coder Desktop/VPN/TunnelHandle.swift

+25-14
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,12 @@ actor TunnelHandle {
1515

1616
init(dylibPath: URL) throws(TunnelHandleError) {
1717
guard let dylibHandle = dlopen(dylibPath.path, RTLD_NOW | RTLD_LOCAL) else {
18-
var errStr = "UNKNOWN"
19-
let e = dlerror()
20-
if e != nil {
21-
errStr = String(cString: e!)
22-
}
23-
throw .dylib(errStr)
18+
throw .dylib(dlerror().flatMap { String(cString: $0) } ?? "UNKNOWN")
2419
}
2520
self.dylibHandle = dylibHandle
2621

2722
guard let startSym = dlsym(dylibHandle, startSymbol) else {
28-
var errStr = "UNKNOWN"
29-
let e = dlerror()
30-
if e != nil {
31-
errStr = String(cString: e!)
32-
}
33-
throw .symbol(startSymbol, errStr)
23+
throw .symbol(startSymbol, dlerror().flatMap { String(cString: $0) } ?? "UNKNOWN")
3424
}
3525
let openTunnelFn = unsafeBitCast(startSym, to: OpenTunnel.self)
3626
tunnelReadPipe = Pipe()
@@ -42,21 +32,42 @@ actor TunnelHandle {
4232
}
4333
}
4434

45-
func close() throws {
46-
dlclose(dylibHandle)
35+
// This could be an isolated deinit in Swift 6.1
36+
func close() throws(TunnelHandleError) {
37+
var errs: [Error] = []
38+
if dlclose(dylibHandle) == 0 {
39+
errs.append(TunnelHandleError.dylib(dlerror().flatMap { String(cString: $0) } ?? "UNKNOWN"))
40+
}
41+
do {
42+
try writeHandle.close()
43+
} catch {
44+
errs.append(error)
45+
}
46+
do {
47+
try readHandle.close()
48+
} catch {
49+
errs.append(error)
50+
}
51+
if !errs.isEmpty {
52+
throw .close(errs)
53+
}
4754
}
4855
}
4956

5057
enum TunnelHandleError: Error {
5158
case dylib(String)
5259
case symbol(String, String)
5360
case openTunnel(OpenTunnelError)
61+
case pipe(Error)
62+
case close([Error])
5463

5564
var description: String {
5665
switch self {
66+
case let .pipe(err): return "pipe error: \(err)"
5767
case let .dylib(d): return d
5868
case let .symbol(symbol, message): return "\(symbol): \(message)"
5969
case let .openTunnel(error): return "OpenTunnel: \(error.message)"
70+
case let .close(errs): return errs.map(\.localizedDescription).joined(separator: ", ")
6071
}
6172
}
6273
}

0 commit comments

Comments
 (0)