Skip to content

Commit 48afa7a

Browse files
feat: add experimental privileged helper (#160)
Closes #135. Closes #142. This PR adds an optional privileged `LaunchDaemon` capable of removing the quarantine flag on a downloaded `.dylib` without prompting the user to enter their password. This is most useful when the Coder deployment updates frequently. <img width="597" alt="image" src="https://github.com/user-attachments/assets/5f51b9a3-93ba-46b7-baa3-37c8bd817733" /> The System Extension communicates directly with the `LaunchDaemon`, meaning a new `.dylib` can be downloaded and executed even if the app was closed, which was previously not possible. I've tested this in a fresh 15.4 VM.
1 parent 9f356e5 commit 48afa7a

15 files changed

+453
-45
lines changed

Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift

+3
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ struct DesktopApp: App {
2525
SettingsView<CoderVPNService>()
2626
.environmentObject(appDelegate.vpn)
2727
.environmentObject(appDelegate.state)
28+
.environmentObject(appDelegate.helper)
2829
}
2930
.windowResizability(.contentSize)
3031
Window("Coder File Sync", id: Windows.fileSync.rawValue) {
@@ -45,10 +46,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
4546
let fileSyncDaemon: MutagenDaemon
4647
let urlHandler: URLHandler
4748
let notifDelegate: NotifDelegate
49+
let helper: HelperService
4850

4951
override init() {
5052
notifDelegate = NotifDelegate()
5153
vpn = CoderVPNService()
54+
helper = HelperService()
5255
let state = AppState(onChange: vpn.configureTunnelProviderProtocol)
5356
vpn.onStart = {
5457
// We don't need this to have finished before the VPN actually starts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import os
2+
import ServiceManagement
3+
4+
// Whilst the GUI app installs the helper, the System Extension communicates
5+
// with it over XPC
6+
@MainActor
7+
class HelperService: ObservableObject {
8+
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperService")
9+
let plistName = "com.coder.Coder-Desktop.Helper.plist"
10+
@Published var state: HelperState = .uninstalled {
11+
didSet {
12+
logger.info("helper daemon state set: \(self.state.description, privacy: .public)")
13+
}
14+
}
15+
16+
init() {
17+
update()
18+
}
19+
20+
func update() {
21+
let daemon = SMAppService.daemon(plistName: plistName)
22+
state = HelperState(status: daemon.status)
23+
}
24+
25+
func install() {
26+
let daemon = SMAppService.daemon(plistName: plistName)
27+
do {
28+
try daemon.register()
29+
} catch let error as NSError {
30+
self.state = .failed(.init(error: error))
31+
} catch {
32+
state = .failed(.unknown(error.localizedDescription))
33+
}
34+
state = HelperState(status: daemon.status)
35+
}
36+
37+
func uninstall() {
38+
let daemon = SMAppService.daemon(plistName: plistName)
39+
do {
40+
try daemon.unregister()
41+
} catch let error as NSError {
42+
self.state = .failed(.init(error: error))
43+
} catch {
44+
state = .failed(.unknown(error.localizedDescription))
45+
}
46+
state = HelperState(status: daemon.status)
47+
}
48+
}
49+
50+
enum HelperState: Equatable {
51+
case uninstalled
52+
case installed
53+
case requiresApproval
54+
case failed(HelperError)
55+
56+
var description: String {
57+
switch self {
58+
case .uninstalled:
59+
"Uninstalled"
60+
case .installed:
61+
"Installed"
62+
case .requiresApproval:
63+
"Requires Approval"
64+
case let .failed(error):
65+
"Failed: \(error.localizedDescription)"
66+
}
67+
}
68+
69+
init(status: SMAppService.Status) {
70+
self = switch status {
71+
case .notRegistered:
72+
.uninstalled
73+
case .enabled:
74+
.installed
75+
case .requiresApproval:
76+
.requiresApproval
77+
case .notFound:
78+
// `Not found`` is the initial state, if `register` has never been called
79+
.uninstalled
80+
@unknown default:
81+
.failed(.unknown("Unknown status: \(status)"))
82+
}
83+
}
84+
}
85+
86+
enum HelperError: Error, Equatable {
87+
case alreadyRegistered
88+
case launchDeniedByUser
89+
case invalidSignature
90+
case unknown(String)
91+
92+
init(error: NSError) {
93+
self = switch error.code {
94+
case kSMErrorAlreadyRegistered:
95+
.alreadyRegistered
96+
case kSMErrorLaunchDeniedByUser:
97+
.launchDeniedByUser
98+
case kSMErrorInvalidSignature:
99+
.invalidSignature
100+
default:
101+
.unknown(error.localizedDescription)
102+
}
103+
}
104+
105+
var localizedDescription: String {
106+
switch self {
107+
case .alreadyRegistered:
108+
"Already registered"
109+
case .launchDeniedByUser:
110+
"Launch denied by user"
111+
case .invalidSignature:
112+
"Invalid signature"
113+
case let .unknown(message):
114+
message
115+
}
116+
}
117+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import LaunchAtLogin
2+
import SwiftUI
3+
4+
struct ExperimentalTab: View {
5+
var body: some View {
6+
Form {
7+
HelperSection()
8+
}.formStyle(.grouped)
9+
}
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import LaunchAtLogin
2+
import ServiceManagement
3+
import SwiftUI
4+
5+
struct HelperSection: View {
6+
var body: some View {
7+
Section {
8+
HelperButton()
9+
Text("""
10+
Coder Connect executes a dynamic library downloaded from the Coder deployment.
11+
Administrator privileges are required when executing a copy of this library for the first time.
12+
Without this helper, these are granted by the user entering their password.
13+
With this helper, this is done automatically.
14+
This is useful if the Coder deployment updates frequently.
15+
16+
Coder Desktop will not execute code unless it has been signed by Coder.
17+
""")
18+
.font(.subheadline)
19+
.foregroundColor(.secondary)
20+
}
21+
}
22+
}
23+
24+
struct HelperButton: View {
25+
@EnvironmentObject var helperService: HelperService
26+
27+
var buttonText: String {
28+
switch helperService.state {
29+
case .uninstalled, .failed:
30+
"Install"
31+
case .installed:
32+
"Uninstall"
33+
case .requiresApproval:
34+
"Open Settings"
35+
}
36+
}
37+
38+
var buttonDescription: String {
39+
switch helperService.state {
40+
case .uninstalled, .installed:
41+
""
42+
case .requiresApproval:
43+
"Requires approval"
44+
case let .failed(err):
45+
err.localizedDescription
46+
}
47+
}
48+
49+
func buttonAction() {
50+
switch helperService.state {
51+
case .uninstalled, .failed:
52+
helperService.install()
53+
if helperService.state == .requiresApproval {
54+
SMAppService.openSystemSettingsLoginItems()
55+
}
56+
case .installed:
57+
helperService.uninstall()
58+
case .requiresApproval:
59+
SMAppService.openSystemSettingsLoginItems()
60+
}
61+
}
62+
63+
var body: some View {
64+
HStack {
65+
Text("Privileged Helper")
66+
Spacer()
67+
Text(buttonDescription)
68+
.foregroundColor(.secondary)
69+
Button(action: buttonAction) {
70+
Text(buttonText)
71+
}
72+
}.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
73+
helperService.update()
74+
}.onAppear {
75+
helperService.update()
76+
}
77+
}
78+
}
79+
80+
#Preview {
81+
HelperSection().environmentObject(HelperService())
82+
}

Coder-Desktop/Coder-Desktop/Views/Settings/Settings.swift

+6
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ struct SettingsView<VPN: VPNService>: View {
1313
.tabItem {
1414
Label("Network", systemImage: "dot.radiowaves.left.and.right")
1515
}.tag(SettingsTab.network)
16+
ExperimentalTab()
17+
.tabItem {
18+
Label("Experimental", systemImage: "gearshape.2")
19+
}.tag(SettingsTab.experimental)
20+
1621
}.frame(width: 600)
1722
.frame(maxHeight: 500)
1823
.scrollContentBackground(.hidden)
@@ -23,4 +28,5 @@ struct SettingsView<VPN: VPNService>: View {
2328
enum SettingsTab: Int {
2429
case general
2530
case network
31+
case experimental
2632
}

Coder-Desktop/Coder-Desktop/XPCInterface.swift

+4-4
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ import VPNLib
1414
}
1515

1616
func connect() {
17-
logger.debug("xpc connect called")
17+
logger.debug("VPN xpc connect called")
1818
guard xpc == nil else {
19-
logger.debug("xpc already exists")
19+
logger.debug("VPN xpc already exists")
2020
return
2121
}
2222
let networkExtDict = Bundle.main.object(forInfoDictionaryKey: "NetworkExtension") as? [String: Any]
@@ -34,14 +34,14 @@ import VPNLib
3434
xpcConn.exportedObject = self
3535
xpcConn.invalidationHandler = { [logger] in
3636
Task { @MainActor in
37-
logger.error("XPC connection invalidated.")
37+
logger.error("VPN XPC connection invalidated.")
3838
self.xpc = nil
3939
self.connect()
4040
}
4141
}
4242
xpcConn.interruptionHandler = { [logger] in
4343
Task { @MainActor in
44-
logger.error("XPC connection interrupted.")
44+
logger.error("VPN XPC connection interrupted.")
4545
self.xpc = nil
4646
self.connect()
4747
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import Foundation
2+
3+
@objc protocol HelperXPCProtocol {
4+
func removeQuarantine(path: String, withReply reply: @escaping (Int32, String) -> Void)
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>Label</key>
6+
<string>com.coder.Coder-Desktop.Helper</string>
7+
<key>BundleProgram</key>
8+
<string>Contents/MacOS/com.coder.Coder-Desktop.Helper</string>
9+
<key>MachServices</key>
10+
<dict>
11+
<!-- $(TeamIdentifierPrefix) isn't populated here, so this value is hardcoded -->
12+
<key>4399GN35BJ.com.coder.Coder-Desktop.Helper</key>
13+
<true/>
14+
</dict>
15+
<key>AssociatedBundleIdentifiers</key>
16+
<array>
17+
<string>com.coder.Coder-Desktop</string>
18+
</array>
19+
</dict>
20+
</plist>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import Foundation
2+
import os
3+
4+
class HelperToolDelegate: NSObject, NSXPCListenerDelegate, HelperXPCProtocol {
5+
private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperToolDelegate")
6+
7+
override init() {
8+
super.init()
9+
}
10+
11+
func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
12+
newConnection.exportedInterface = NSXPCInterface(with: HelperXPCProtocol.self)
13+
newConnection.exportedObject = self
14+
newConnection.invalidationHandler = { [weak self] in
15+
self?.logger.info("Helper XPC connection invalidated")
16+
}
17+
newConnection.interruptionHandler = { [weak self] in
18+
self?.logger.debug("Helper XPC connection interrupted")
19+
}
20+
logger.info("new active connection")
21+
newConnection.resume()
22+
return true
23+
}
24+
25+
func removeQuarantine(path: String, withReply reply: @escaping (Int32, String) -> Void) {
26+
guard isCoderDesktopDylib(at: path) else {
27+
reply(1, "Path is not to a Coder Desktop dylib: \(path)")
28+
return
29+
}
30+
31+
let task = Process()
32+
let pipe = Pipe()
33+
34+
task.standardOutput = pipe
35+
task.standardError = pipe
36+
task.arguments = ["-d", "com.apple.quarantine", path]
37+
task.executableURL = URL(fileURLWithPath: "/usr/bin/xattr")
38+
39+
do {
40+
try task.run()
41+
} catch {
42+
reply(1, "Failed to start command: \(error)")
43+
return
44+
}
45+
46+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
47+
let output = String(data: data, encoding: .utf8) ?? ""
48+
49+
task.waitUntilExit()
50+
reply(task.terminationStatus, output)
51+
}
52+
}
53+
54+
func isCoderDesktopDylib(at rawPath: String) -> Bool {
55+
let url = URL(fileURLWithPath: rawPath)
56+
.standardizedFileURL
57+
.resolvingSymlinksInPath()
58+
59+
// *Must* be within the Coder Desktop System Extension sandbox
60+
let requiredPrefix = ["/", "var", "root", "Library", "Containers",
61+
"com.coder.Coder-Desktop.VPN"]
62+
guard url.pathComponents.starts(with: requiredPrefix) else { return false }
63+
guard url.pathExtension.lowercased() == "dylib" else { return false }
64+
guard FileManager.default.fileExists(atPath: url.path) else { return false }
65+
return true
66+
}
67+
68+
let delegate = HelperToolDelegate()
69+
let listener = NSXPCListener(machServiceName: "4399GN35BJ.com.coder.Coder-Desktop.Helper")
70+
listener.delegate = delegate
71+
listener.resume()
72+
RunLoop.main.run()

0 commit comments

Comments
 (0)