Skip to content

Commit 87ec698

Browse files
committed
fix: unquarantine dylib after download
1 parent df3d755 commit 87ec698

12 files changed

+161
-52
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/VPNMenu.swift

+19-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("Open System Preferences") }
54+
}.buttonStyle(.plain)
55+
} else {
56+
AuthButton<VPN, S>()
57+
}
5658
Button {
5759
openSettings()
5860
appActivate()
@@ -84,10 +86,18 @@ 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+
// TODO: Check this still works in a new macOS version
96+
// https://gist.github.com/rmcdongit/f66ff91e0dad78d4d6346a75ded4b751?permalink_comment_id=5261757
97+
// swiftlint:disable:next line_length
98+
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.ExtensionsPreferences?extensionPointIdentifier=com.apple.system_extension.network_extension.extension-point")!)
99+
}
100+
91101
#Preview {
92102
VPNMenu<PreviewVPN, PreviewSession>().frame(width: 256)
93103
.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/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

+41-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,13 @@ 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+
NSError(
95+
domain: "\(Bundle.main.bundleIdentifier!).Manager",
96+
code: -1,
97+
userInfo: [NSLocalizedDescriptionKey: "Tunnel read loop failed: \(error.localizedDescription)"]
98+
)
99+
)
89100
return
90101
}
91102
logger.info("tunnel read loop exited")
@@ -227,6 +238,9 @@ enum ManagerError: Error {
227238
case serverInfo(String)
228239
case errorResponse(msg: String)
229240
case noTunnelFileDescriptor
241+
case noApp
242+
case permissionDenied
243+
case tunnelFail(any Error)
230244

231245
var description: String {
232246
switch self {
@@ -248,6 +262,12 @@ enum ManagerError: Error {
248262
msg
249263
case .noTunnelFileDescriptor:
250264
"Could not find a tunnel file descriptor"
265+
case .noApp:
266+
"The VPN must be started with the app open during first-time setup."
267+
case .permissionDenied:
268+
"Permission was not granted to execute the CoderVPN dylib"
269+
case let .tunnelFail(err):
270+
"Failed to communicate with dylib over tunnel: \(err)"
251271
}
252272
}
253273
}
@@ -272,3 +292,23 @@ func writeVpnLog(_ log: Vpn_Log) {
272292
let fields = log.fields.map { "\($0.name): \($0.value)" }.joined(separator: ", ")
273293
logger.log(level: level, "\(log.message, privacy: .public): \(fields, privacy: .public)")
274294
}
295+
296+
private func removeQuarantine(_ dest: URL) async throws(ManagerError) {
297+
var flag: AnyObject?
298+
let file = NSURL(fileURLWithPath: dest.path)
299+
try? file.getResourceValue(&flag, forKey: kCFURLQuarantinePropertiesKey as URLResourceKey)
300+
if flag != nil {
301+
guard let conn = globalXPCListenerDelegate.conn else {
302+
throw .noApp
303+
}
304+
// Wait for unsandboxed app to accept our file
305+
let success = await withCheckedContinuation { [dest] continuation in
306+
conn.removeQuarantine(path: dest.path) { success in
307+
continuation.resume(returning: success)
308+
}
309+
}
310+
if !success {
311+
throw .permissionDenied
312+
}
313+
}
314+
}

Coder Desktop/VPN/PacketTunnelProvider.swift

+32-12
Original file line numberDiff line numberDiff line change
@@ -43,47 +43,73 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
4343
return nil
4444
}
4545

46+
// swiftlint:disable:next function_body_length
4647
override func startTunnel(
4748
options _: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void
4849
) {
4950
logger.info("startTunnel called")
5051
guard manager == nil else {
5152
logger.error("startTunnel called with non-nil Manager")
52-
completionHandler(PTPError.alreadyRunning)
53+
completionHandler(
54+
NSError(
55+
domain: "\(Bundle.main.bundleIdentifier!).PTP",
56+
code: -1,
57+
userInfo: [NSLocalizedDescriptionKey: "Already running"]
58+
)
59+
)
5360
return
5461
}
5562
guard let proto = protocolConfiguration as? NETunnelProviderProtocol,
5663
let baseAccessURL = proto.serverAddress
5764
else {
5865
logger.error("startTunnel called with nil protocolConfiguration")
59-
completionHandler(PTPError.missingConfiguration)
66+
completionHandler(
67+
NSError(
68+
domain: "\(Bundle.main.bundleIdentifier!).PTP",
69+
code: -1,
70+
userInfo: [NSLocalizedDescriptionKey: "Missing Configuration"]
71+
)
72+
)
6073
return
6174
}
6275
// HACK: We can't write to the system keychain, and the NE can't read the user keychain.
6376
guard let token = proto.providerConfiguration?["token"] as? String else {
6477
logger.error("startTunnel called with nil token")
65-
completionHandler(PTPError.missingToken)
78+
completionHandler(
79+
NSError(
80+
domain: "\(Bundle.main.bundleIdentifier!).PTP",
81+
code: -1,
82+
userInfo: [NSLocalizedDescriptionKey: "Missing Token"]
83+
)
84+
)
6685
return
6786
}
6887
logger.debug("retrieved token & access URL")
6988
let completionHandler = CallbackWrapper(completionHandler)
7089
Task {
7190
do throws(ManagerError) {
7291
logger.debug("creating manager")
73-
manager = try await Manager(
92+
let manager = try await Manager(
7493
with: self,
7594
cfg: .init(
7695
apiToken: token, serverUrl: .init(string: baseAccessURL)!
7796
)
7897
)
7998
globalXPCListenerDelegate.vpnXPCInterface.manager = manager
8099
logger.debug("starting vpn")
81-
try await manager!.startVPN()
100+
try await manager.startVPN()
82101
logger.info("vpn started")
102+
self.manager = manager
83103
completionHandler(nil)
84104
} catch {
85105
logger.error("error starting manager: \(error.description, privacy: .public)")
86-
completionHandler(error as NSError)
106+
completionHandler(
107+
NSError(
108+
domain: "\(Bundle.main.bundleIdentifier!).Manager",
109+
code: -1,
110+
userInfo: [NSLocalizedDescriptionKey: error.description]
111+
)
112+
)
87113
}
88114
}
89115
}
@@ -152,9 +178,3 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
152178
try await setTunnelNetworkSettings(currentSettings)
153179
}
154180
}
155-
156-
enum PTPError: Error {
157-
case alreadyRunning
158-
case missingConfiguration
159-
case missingToken
160-
}

Coder Desktop/VPNLib/Util.swift

+3-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
}

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)