Skip to content

Commit 2bfe5bd

Browse files
fix: unquarantine dylib after download (#38)
1 parent df3d755 commit 2bfe5bd

14 files changed

+145
-54
lines changed

Coder Desktop/Coder Desktop/Coder_Desktop.entitlements

-6
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,9 @@
88
</array>
99
<key>com.apple.developer.system-extension.install</key>
1010
<true/>
11-
<key>com.apple.security.app-sandbox</key>
12-
<true/>
1311
<key>com.apple.security.application-groups</key>
1412
<array>
1513
<string>$(TeamIdentifierPrefix)com.coder.Coder-Desktop</string>
1614
</array>
17-
<key>com.apple.security.files.user-selected.read-only</key>
18-
<true/>
19-
<key>com.apple.security.network.client</key>
20-
<true/>
2115
</dict>
2216
</plist>

Coder Desktop/Coder Desktop/NetworkExtension.swift

+5-4
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,13 @@ enum NetworkExtensionState: Equatable {
2424
/// An actor that handles configuring, enabling, and disabling the VPN tunnel via the
2525
/// NetworkExtension APIs.
2626
extension CoderVPNService {
27-
func hasNetworkExtensionConfig() async -> Bool {
27+
func loadNetworkExtensionConfig() async {
2828
do {
29-
_ = try await getTunnelManager()
30-
return true
29+
let tm = try await getTunnelManager()
30+
neState = .disabled
31+
serverAddress = tm.protocolConfiguration?.serverAddress
3132
} catch {
32-
return false
33+
neState = .unconfigured
3334
}
3435
}
3536

Coder Desktop/Coder Desktop/VPNService.swift

+4-5
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,13 @@ final class CoderVPNService: NSObject, VPNService {
6363
// only stores a weak reference to the delegate.
6464
var systemExtnDelegate: SystemExtensionDelegate<CoderVPNService>?
6565

66+
var serverAddress: String?
67+
6668
override init() {
6769
super.init()
6870
installSystemExtension()
6971
Task {
70-
neState = if await hasNetworkExtensionConfig() {
71-
.disabled
72-
} else {
73-
.unconfigured
74-
}
72+
await loadNetworkExtensionConfig()
7573
}
7674
xpc.connect()
7775
xpc.getPeerState()
@@ -115,6 +113,7 @@ final class CoderVPNService: NSObject, VPNService {
115113
func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?) {
116114
Task {
117115
if let proto {
116+
serverAddress = proto.serverAddress
118117
await configureNetworkExtension(proto: proto)
119118
// this just configures the VPN, it doesn't enable it
120119
tunnelState = .disabled

Coder Desktop/Coder Desktop/Views/AuthButton.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ struct AuthButton<VPN: VPNService, S: Session>: View {
1717
}
1818
} label: {
1919
ButtonRowView {
20-
Text(session.hasSession ? "Sign Out" : "Sign In")
20+
Text(session.hasSession ? "Sign out" : "Sign in")
2121
}
2222
}.buttonStyle(.plain)
2323
}

Coder Desktop/Coder Desktop/Views/VPNMenu.swift

+20-9
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,7 @@ struct VPNMenu<VPN: VPNService, S: Session>: View {
3131
Text("Workspace Agents")
3232
.font(.headline)
3333
.foregroundColor(.gray)
34-
if session.hasSession {
35-
VPNState<VPN>()
36-
} else {
37-
Text("Sign in to use CoderVPN")
38-
.font(.body)
39-
.foregroundColor(.gray)
40-
}
34+
VPNState<VPN, S>()
4135
}.padding([.horizontal, .top], Theme.Size.trayInset)
4236
Agents<VPN, S>()
4337
// Trailing stack
@@ -52,7 +46,15 @@ struct VPNMenu<VPN: VPNService, S: Session>: View {
5246
}.buttonStyle(.plain)
5347
TrayDivider()
5448
}
55-
AuthButton<VPN, S>()
49+
if vpn.state == .failed(.systemExtensionError(.needsUserApproval)) {
50+
Button {
51+
openSystemExtensionSettings()
52+
} label: {
53+
ButtonRowView { Text("Approve in System Settings") }
54+
}.buttonStyle(.plain)
55+
} else {
56+
AuthButton<VPN, S>()
57+
}
5658
Button {
5759
openSettings()
5860
appActivate()
@@ -84,10 +86,19 @@ struct VPNMenu<VPN: VPNService, S: Session>: View {
8486
private var vpnDisabled: Bool {
8587
!session.hasSession ||
8688
vpn.state == .connecting ||
87-
vpn.state == .disconnecting
89+
vpn.state == .disconnecting ||
90+
vpn.state == .failed(.systemExtensionError(.needsUserApproval))
8891
}
8992
}
9093

94+
func openSystemExtensionSettings() {
95+
// Sourced from:
96+
// https://gist.github.com/rmcdongit/f66ff91e0dad78d4d6346a75ded4b751?permalink_comment_id=5261757
97+
// We'll need to ensure this continues to work in future macOS versions
98+
// swiftlint:disable:next line_length
99+
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.ExtensionsPreferences?extensionPointIdentifier=com.apple.system_extension.network_extension.extension-point")!)
100+
}
101+
91102
#Preview {
92103
VPNMenu<PreviewVPN, PreviewSession>().frame(width: 256)
93104
.environmentObject(PreviewVPN())

Coder Desktop/Coder Desktop/Views/VPNState.swift

+15-6
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,35 @@
11
import SwiftUI
22

3-
struct VPNState<VPN: VPNService>: View {
3+
struct VPNState<VPN: VPNService, S: Session>: View {
44
@EnvironmentObject var vpn: VPN
5+
@EnvironmentObject var session: S
56

67
let inspection = Inspection<Self>()
78

89
var body: some View {
910
Group {
10-
switch vpn.state {
11-
case .disabled:
12-
Text("Enable CoderVPN to see agents")
11+
switch (vpn.state, session.hasSession) {
12+
case (.failed(.systemExtensionError(.needsUserApproval)), _):
13+
Text("Awaiting System Extension approval")
14+
.font(.body)
15+
.foregroundStyle(.gray)
16+
case (_, false):
17+
Text("Sign in to use CoderVPN")
1318
.font(.body)
1419
.foregroundColor(.gray)
15-
case .connecting, .disconnecting:
20+
case (.disabled, _):
21+
Text("Enable CoderVPN to see agents")
22+
.font(.body)
23+
.foregroundStyle(.gray)
24+
case (.connecting, _), (.disconnecting, _):
1625
HStack {
1726
Spacer()
1827
ProgressView(
1928
vpn.state == .connecting ? "Starting CoderVPN..." : "Stopping CoderVPN..."
2029
).padding()
2130
Spacer()
2231
}
23-
case let .failed(vpnErr):
32+
case let (.failed(vpnErr), _):
2433
Text("\(vpnErr.description)")
2534
.font(.headline)
2635
.foregroundColor(.red)

Coder Desktop/Coder Desktop/XPCInterface.swift

+35
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,39 @@ import VPNLib
6464
svc.onExtensionPeerUpdate(data)
6565
}
6666
}
67+
68+
// The NE has verified the dylib and knows better than Gatekeeper
69+
func removeQuarantine(path: String, reply: @escaping (Bool) -> Void) {
70+
let reply = CallbackWrapper(reply)
71+
Task { @MainActor in
72+
let prompt = """
73+
Coder Desktop wants to execute code downloaded from \
74+
\(svc.serverAddress ?? "the Coder deployment"). The code has been \
75+
verified to be signed by Coder.
76+
"""
77+
let source = """
78+
do shell script "xattr -d com.apple.quarantine \(path)" \
79+
with prompt "\(prompt)" \
80+
with administrator privileges
81+
"""
82+
let success = await withCheckedContinuation { continuation in
83+
guard let script = NSAppleScript(source: source) else {
84+
continuation.resume(returning: false)
85+
return
86+
}
87+
// Run on a background thread
88+
Task.detached {
89+
var error: NSDictionary?
90+
script.executeAndReturnError(&error)
91+
if let error {
92+
self.logger.error("AppleScript error: \(error)")
93+
continuation.resume(returning: false)
94+
} else {
95+
continuation.resume(returning: true)
96+
}
97+
}
98+
}
99+
reply(success)
100+
}
101+
}
67102
}

Coder Desktop/Coder DesktopTests/VPNMenuTests.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ struct VPNMenuTests {
2727
let toggle = try view.find(ViewType.Toggle.self)
2828
#expect(toggle.isDisabled())
2929
#expect(throws: Never.self) { try view.find(text: "Sign in to use CoderVPN") }
30-
#expect(throws: Never.self) { try view.find(button: "Sign In") }
30+
#expect(throws: Never.self) { try view.find(button: "Sign in") }
3131
}
3232
}
3333
}

Coder Desktop/Coder DesktopTests/VPNStateTests.swift

+6-3
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@ import ViewInspector
77
@Suite(.timeLimit(.minutes(1)))
88
struct VPNStateTests {
99
let vpn: MockVPNService
10-
let sut: VPNState<MockVPNService>
10+
let session: MockSession
11+
let sut: VPNState<MockVPNService, MockSession>
1112
let view: any View
1213

1314
init() {
1415
vpn = MockVPNService()
15-
sut = VPNState<MockVPNService>()
16-
view = sut.environmentObject(vpn)
16+
sut = VPNState<MockVPNService, MockSession>()
17+
session = MockSession()
18+
session.hasSession = true
19+
view = sut.environmentObject(vpn).environmentObject(session)
1720
}
1821

1922
@Test

Coder Desktop/VPN/Manager.swift

+37-1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ actor Manager {
4646
} catch {
4747
throw .validation(error)
4848
}
49+
50+
// HACK: The downloaded dylib may be quarantined, but we've validated it's signature
51+
// so it's safe to execute. However, this SE must be sandboxed, so we defer to the app.
52+
try await removeQuarantine(dest)
53+
4954
do {
5055
try tunnelHandle = TunnelHandle(dylibPath: dest)
5156
} catch {
@@ -85,7 +90,9 @@ actor Manager {
8590
} catch {
8691
logger.error("tunnel read loop failed: \(error.localizedDescription, privacy: .public)")
8792
try await tunnelHandle.close()
88-
ptp.cancelTunnelWithError(error)
93+
ptp.cancelTunnelWithError(
94+
makeNSError(suffix: "Manager", desc: "Tunnel read loop failed: \(error.localizedDescription)")
95+
)
8996
return
9097
}
9198
logger.info("tunnel read loop exited")
@@ -227,6 +234,9 @@ enum ManagerError: Error {
227234
case serverInfo(String)
228235
case errorResponse(msg: String)
229236
case noTunnelFileDescriptor
237+
case noApp
238+
case permissionDenied
239+
case tunnelFail(any Error)
230240

231241
var description: String {
232242
switch self {
@@ -248,6 +258,12 @@ enum ManagerError: Error {
248258
msg
249259
case .noTunnelFileDescriptor:
250260
"Could not find a tunnel file descriptor"
261+
case .noApp:
262+
"The VPN must be started with the app open during first-time setup."
263+
case .permissionDenied:
264+
"Permission was not granted to execute the CoderVPN dylib"
265+
case let .tunnelFail(err):
266+
"Failed to communicate with dylib over tunnel: \(err)"
251267
}
252268
}
253269
}
@@ -272,3 +288,23 @@ func writeVpnLog(_ log: Vpn_Log) {
272288
let fields = log.fields.map { "\($0.name): \($0.value)" }.joined(separator: ", ")
273289
logger.log(level: level, "\(log.message, privacy: .public): \(fields, privacy: .public)")
274290
}
291+
292+
private func removeQuarantine(_ dest: URL) async throws(ManagerError) {
293+
var flag: AnyObject?
294+
let file = NSURL(fileURLWithPath: dest.path)
295+
try? file.getResourceValue(&flag, forKey: kCFURLQuarantinePropertiesKey as URLResourceKey)
296+
if flag != nil {
297+
guard let conn = globalXPCListenerDelegate.conn else {
298+
throw .noApp
299+
}
300+
// Wait for unsandboxed app to accept our file
301+
let success = await withCheckedContinuation { [dest] continuation in
302+
conn.removeQuarantine(path: dest.path) { success in
303+
continuation.resume(returning: success)
304+
}
305+
}
306+
if !success {
307+
throw .permissionDenied
308+
}
309+
}
310+
}

Coder Desktop/VPN/PacketTunnelProvider.swift

+9-12
Original file line numberDiff line numberDiff line change
@@ -49,41 +49,44 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
4949
logger.info("startTunnel called")
5050
guard manager == nil else {
5151
logger.error("startTunnel called with non-nil Manager")
52-
completionHandler(PTPError.alreadyRunning)
52+
completionHandler(makeNSError(suffix: "PTP", desc: "Already running"))
5353
return
5454
}
5555
guard let proto = protocolConfiguration as? NETunnelProviderProtocol,
5656
let baseAccessURL = proto.serverAddress
5757
else {
5858
logger.error("startTunnel called with nil protocolConfiguration")
59-
completionHandler(PTPError.missingConfiguration)
59+
completionHandler(makeNSError(suffix: "PTP", desc: "Missing Configuration"))
6060
return
6161
}
6262
// HACK: We can't write to the system keychain, and the NE can't read the user keychain.
6363
guard let token = proto.providerConfiguration?["token"] as? String else {
6464
logger.error("startTunnel called with nil token")
65-
completionHandler(PTPError.missingToken)
65+
completionHandler(makeNSError(suffix: "PTP", desc: "Missing Token"))
6666
return
6767
}
6868
logger.debug("retrieved token & access URL")
6969
let completionHandler = CallbackWrapper(completionHandler)
7070
Task {
7171
do throws(ManagerError) {
7272
logger.debug("creating manager")
73-
manager = try await Manager(
73+
let manager = try await Manager(
7474
with: self,
7575
cfg: .init(
7676
apiToken: token, serverUrl: .init(string: baseAccessURL)!
7777
)
7878
)
7979
globalXPCListenerDelegate.vpnXPCInterface.manager = manager
8080
logger.debug("starting vpn")
81-
try await manager!.startVPN()
81+
try await manager.startVPN()
8282
logger.info("vpn started")
83+
self.manager = manager
8384
completionHandler(nil)
8485
} catch {
8586
logger.error("error starting manager: \(error.description, privacy: .public)")
86-
completionHandler(error as NSError)
87+
completionHandler(
88+
makeNSError(suffix: "Manager", desc: error.description)
89+
)
8790
}
8891
}
8992
}
@@ -152,9 +155,3 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
152155
try await setTunnelNetworkSettings(currentSettings)
153156
}
154157
}
155-
156-
enum PTPError: Error {
157-
case alreadyRunning
158-
case missingConfiguration
159-
case missingToken
160-
}

Coder Desktop/VPNLib/Util.swift

+11-3
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
public struct CallbackWrapper<T, U>: @unchecked Sendable {
2-
private let block: (T?) -> U
2+
private let block: (T) -> U
33

4-
public init(_ block: @escaping (T?) -> U) {
4+
public init(_ block: @escaping (T) -> U) {
55
self.block = block
66
}
77

8-
public func callAsFunction(_ error: T?) -> U {
8+
public func callAsFunction(_ error: T) -> U {
99
block(error)
1010
}
1111
}
@@ -21,3 +21,11 @@ public struct CompletionWrapper<T>: @unchecked Sendable {
2121
block()
2222
}
2323
}
24+
25+
public func makeNSError(suffix: String, code: Int = -1, desc: String) -> NSError {
26+
NSError(
27+
domain: "\(Bundle.main.bundleIdentifier!).\(suffix)",
28+
code: code,
29+
userInfo: [NSLocalizedDescriptionKey: desc]
30+
)
31+
}

Coder Desktop/VPNLib/XPC.swift

+1
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ import Foundation
1010
@objc public protocol VPNXPCClientCallbackProtocol {
1111
// data is a serialized `Vpn_PeerUpdate`
1212
func onPeerUpdate(_ data: Data)
13+
func removeQuarantine(path: String, reply: @escaping (Bool) -> Void)
1314
}

0 commit comments

Comments
 (0)