Skip to content

feat: add experimental privileged helper #160

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 19, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
Original file line number Diff line number Diff line change
@@ -25,6 +25,7 @@ struct DesktopApp: App {
SettingsView<CoderVPNService>()
.environmentObject(appDelegate.vpn)
.environmentObject(appDelegate.state)
.environmentObject(appDelegate.helper)
}
.windowResizability(.contentSize)
Window("Coder File Sync", id: Windows.fileSync.rawValue) {
@@ -45,10 +46,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let fileSyncDaemon: MutagenDaemon
let urlHandler: URLHandler
let notifDelegate: NotifDelegate
let helper: HelperService

override init() {
notifDelegate = NotifDelegate()
vpn = CoderVPNService()
helper = HelperService()
let state = AppState(onChange: vpn.configureTunnelProviderProtocol)
vpn.onStart = {
// We don't need this to have finished before the VPN actually starts
117 changes: 117 additions & 0 deletions Coder-Desktop/Coder-Desktop/HelperService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import os
import ServiceManagement

// Whilst the GUI app installs the helper, the System Extension communicates
// with it over XPC
@MainActor
class HelperService: ObservableObject {
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperService")
let plistName = "com.coder.Coder-Desktop.Helper.plist"
@Published var state: HelperState = .uninstalled {
didSet {
logger.info("helper daemon state set: \(self.state.description, privacy: .public)")
}
}

init() {
update()
}

func update() {
let daemon = SMAppService.daemon(plistName: plistName)
state = HelperState(status: daemon.status)
}

func install() {
let daemon = SMAppService.daemon(plistName: plistName)
do {
try daemon.register()
} catch let error as NSError {
self.state = .failed(.init(error: error))
} catch {
state = .failed(.unknown(error.localizedDescription))
}
state = HelperState(status: daemon.status)
}

func uninstall() {
let daemon = SMAppService.daemon(plistName: plistName)
do {
try daemon.unregister()
} catch let error as NSError {
self.state = .failed(.init(error: error))
} catch {
state = .failed(.unknown(error.localizedDescription))
}
state = HelperState(status: daemon.status)
}
}

enum HelperState: Equatable {
case uninstalled
case installed
case requiresApproval
case failed(HelperError)

var description: String {
switch self {
case .uninstalled:
"Uninstalled"
case .installed:
"Installed"
case .requiresApproval:
"Requires Approval"
case let .failed(error):
"Failed: \(error.localizedDescription)"
}
}

init(status: SMAppService.Status) {
self = switch status {
case .notRegistered:
.uninstalled
case .enabled:
.installed
case .requiresApproval:
.requiresApproval
case .notFound:
// `Not found`` is the initial state, if `register` has never been called
.uninstalled
@unknown default:
.failed(.unknown("Unknown status: \(status)"))
}
}
}

enum HelperError: Error, Equatable {
case alreadyRegistered
case launchDeniedByUser
case invalidSignature
case unknown(String)

init(error: NSError) {
self = switch error.code {
case kSMErrorAlreadyRegistered:
.alreadyRegistered
case kSMErrorLaunchDeniedByUser:
.launchDeniedByUser
case kSMErrorInvalidSignature:
.invalidSignature
default:
.unknown(error.localizedDescription)
}
}

var localizedDescription: String {
switch self {
case .alreadyRegistered:
"Already registered"
case .launchDeniedByUser:
"Launch denied by user"
case .invalidSignature:
"Invalid signature"
case let .unknown(message):
message
}
}
}
10 changes: 10 additions & 0 deletions Coder-Desktop/Coder-Desktop/Views/Settings/ExperimentalTab.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import LaunchAtLogin
import SwiftUI

struct ExperimentalTab: View {
var body: some View {
Form {
HelperSection()
}.formStyle(.grouped)
}
}
82 changes: 82 additions & 0 deletions Coder-Desktop/Coder-Desktop/Views/Settings/HelperSection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import LaunchAtLogin
import ServiceManagement
import SwiftUI

struct HelperSection: View {
var body: some View {
Section {
HelperButton()
Text("""
Coder Connect executes a dynamic library downloaded from the Coder deployment.
Administrator privileges are required when executing a copy of this library for the first time.
Without this helper, these are granted by the user entering their password.
With this helper, this is done automatically.
This is useful if the Coder deployment updates frequently.

Coder Desktop will not execute code unless it has been signed by Coder.
""")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}

struct HelperButton: View {
@EnvironmentObject var helperService: HelperService

var buttonText: String {
switch helperService.state {
case .uninstalled, .failed:
"Install"
case .installed:
"Uninstall"
case .requiresApproval:
"Open Settings"
}
}

var buttonDescription: String {
switch helperService.state {
case .uninstalled, .installed:
""
case .requiresApproval:
"Requires approval"
case let .failed(err):
err.localizedDescription
}
}

func buttonAction() {
switch helperService.state {
case .uninstalled, .failed:
helperService.install()
if helperService.state == .requiresApproval {
SMAppService.openSystemSettingsLoginItems()
}
case .installed:
helperService.uninstall()
case .requiresApproval:
SMAppService.openSystemSettingsLoginItems()
}
}

var body: some View {
HStack {
Text("Privileged Helper")
Spacer()
Text(buttonDescription)
.foregroundColor(.secondary)
Button(action: buttonAction) {
Text(buttonText)
}
}.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
helperService.update()
}.onAppear {
helperService.update()
}
}
}

#Preview {
HelperSection().environmentObject(HelperService())
}
6 changes: 6 additions & 0 deletions Coder-Desktop/Coder-Desktop/Views/Settings/Settings.swift
Original file line number Diff line number Diff line change
@@ -13,6 +13,11 @@ struct SettingsView<VPN: VPNService>: View {
.tabItem {
Label("Network", systemImage: "dot.radiowaves.left.and.right")
}.tag(SettingsTab.network)
ExperimentalTab()
.tabItem {
Label("Experimental", systemImage: "gearshape.2")
}.tag(SettingsTab.experimental)

}.frame(width: 600)
.frame(maxHeight: 500)
.scrollContentBackground(.hidden)
@@ -23,4 +28,5 @@ struct SettingsView<VPN: VPNService>: View {
enum SettingsTab: Int {
case general
case network
case experimental
}
8 changes: 4 additions & 4 deletions Coder-Desktop/Coder-Desktop/XPCInterface.swift
Original file line number Diff line number Diff line change
@@ -14,9 +14,9 @@ import VPNLib
}

func connect() {
logger.debug("xpc connect called")
logger.debug("VPN xpc connect called")
guard xpc == nil else {
logger.debug("xpc already exists")
logger.debug("VPN xpc already exists")
return
}
let networkExtDict = Bundle.main.object(forInfoDictionaryKey: "NetworkExtension") as? [String: Any]
@@ -34,14 +34,14 @@ import VPNLib
xpcConn.exportedObject = self
xpcConn.invalidationHandler = { [logger] in
Task { @MainActor in
logger.error("XPC connection invalidated.")
logger.error("VPN XPC connection invalidated.")
self.xpc = nil
self.connect()
}
}
xpcConn.interruptionHandler = { [logger] in
Task { @MainActor in
logger.error("XPC connection interrupted.")
logger.error("VPN XPC connection interrupted.")
self.xpc = nil
self.connect()
}
5 changes: 5 additions & 0 deletions Coder-Desktop/Coder-DesktopHelper/HelperXPCProtocol.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Foundation

@objc protocol HelperXPCProtocol {
func removeQuarantine(path: String, withReply reply: @escaping (Int32, String) -> Void)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.coder.Coder-Desktop.Helper</string>
<key>BundleProgram</key>
<string>Contents/MacOS/com.coder.Coder-Desktop.Helper</string>
<key>MachServices</key>
<dict>
<!-- $(TeamIdentifierPrefix) isn't populated here, so this value is hardcoded -->
<key>4399GN35BJ.com.coder.Coder-Desktop.Helper</key>
<true/>
</dict>
<key>AssociatedBundleIdentifiers</key>
<array>
<string>com.coder.Coder-Desktop</string>
</array>
</dict>
</plist>
72 changes: 72 additions & 0 deletions Coder-Desktop/Coder-DesktopHelper/main.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Foundation
import os

class HelperToolDelegate: NSObject, NSXPCListenerDelegate, HelperXPCProtocol {
private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperToolDelegate")

override init() {
super.init()
}

func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
newConnection.exportedInterface = NSXPCInterface(with: HelperXPCProtocol.self)
newConnection.exportedObject = self
newConnection.invalidationHandler = { [weak self] in
self?.logger.info("Helper XPC connection invalidated")
}
newConnection.interruptionHandler = { [weak self] in
self?.logger.debug("Helper XPC connection interrupted")
}
logger.info("new active connection")
newConnection.resume()
return true
}

func removeQuarantine(path: String, withReply reply: @escaping (Int32, String) -> Void) {
guard isCoderDesktopDylib(at: path) else {
reply(1, "Path is not to a Coder Desktop dylib: \(path)")
return
}

let task = Process()
let pipe = Pipe()

task.standardOutput = pipe
task.standardError = pipe
task.arguments = ["-d", "com.apple.quarantine", path]
task.executableURL = URL(fileURLWithPath: "/usr/bin/xattr")

do {
try task.run()
} catch {
reply(1, "Failed to start command: \(error)")
return
}

let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""

task.waitUntilExit()
reply(task.terminationStatus, output)
}
}

func isCoderDesktopDylib(at rawPath: String) -> Bool {
let url = URL(fileURLWithPath: rawPath)
.standardizedFileURL
Copy link
Member Author

@ethanndickson ethanndickson May 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We standardize the URL to prevent an attack that starts with the prefix but uses .. to navigate to a file outside of the sandbox container.

.resolvingSymlinksInPath()

// *Must* be within the Coder Desktop System Extension sandbox
let requiredPrefix = ["/", "var", "root", "Library", "Containers",
Copy link
Member Author

@ethanndickson ethanndickson May 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any file created by the system extension will have this prefix, so I think it's fine to hardcode.
In case this was somehow exploited to unquarantine some other .dylib in the container:

  • macOS (dlopen) still won't load it unless it's signed by the same team as the app
  • The system extension validates the signature of the dylib before opening it

"com.coder.Coder-Desktop.VPN"]
guard url.pathComponents.starts(with: requiredPrefix) else { return false }
guard url.pathExtension.lowercased() == "dylib" else { return false }
guard FileManager.default.fileExists(atPath: url.path) else { return false }
return true
}

let delegate = HelperToolDelegate()
let listener = NSXPCListener(machServiceName: "4399GN35BJ.com.coder.Coder-Desktop.Helper")
listener.delegate = delegate
listener.resume()
RunLoop.main.run()
43 changes: 43 additions & 0 deletions Coder-Desktop/VPN/AppXPCListener.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Foundation
import NetworkExtension
import os
import VPNLib

final class AppXPCListener: NSObject, NSXPCListenerDelegate, @unchecked Sendable {
let vpnXPCInterface = XPCInterface()
private var activeConnection: NSXPCConnection?
private var connMutex: NSLock = .init()

var conn: VPNXPCClientCallbackProtocol? {
connMutex.lock()
defer { connMutex.unlock() }

let conn = activeConnection?.remoteObjectProxy as? VPNXPCClientCallbackProtocol
return conn
}

func setActiveConnection(_ connection: NSXPCConnection?) {
connMutex.lock()
defer { connMutex.unlock() }
activeConnection = connection
}

func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
newConnection.exportedInterface = NSXPCInterface(with: VPNXPCProtocol.self)
newConnection.exportedObject = vpnXPCInterface
newConnection.remoteObjectInterface = NSXPCInterface(with: VPNXPCClientCallbackProtocol.self)
newConnection.invalidationHandler = { [weak self] in
logger.info("active connection dead")
self?.setActiveConnection(nil)
}
newConnection.interruptionHandler = { [weak self] in
logger.debug("connection interrupted")
self?.setActiveConnection(nil)
}
logger.info("new active connection")
setActiveConnection(newConnection)

newConnection.resume()
return true
}
}
55 changes: 55 additions & 0 deletions Coder-Desktop/VPN/HelperXPCSpeaker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Foundation
import os

final class HelperXPCSpeaker: @unchecked Sendable {
private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperXPCSpeaker")
private var connection: NSXPCConnection?

func tryRemoveQuarantine(path: String) async -> Bool {
let conn = connect()
Copy link
Member Author

@ethanndickson ethanndickson May 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LaunchDaemons are ran on-demand. Just attempting to communicate with it over XPC is enough to get it to start (as the OS knows which daemons use which XPC services, defined in the .plist)

return await withCheckedContinuation { continuation in
guard let proxy = conn.remoteObjectProxyWithErrorHandler({ err in
self.logger.error("Failed to connect to HelperXPC \(err)")
continuation.resume(returning: false)
}) as? HelperXPCProtocol else {
self.logger.error("Failed to get proxy for HelperXPC")
continuation.resume(returning: false)
return
}
proxy.removeQuarantine(path: path) { status, output in
if status == 0 {
self.logger.info("Successfully removed quarantine for \(path)")
continuation.resume(returning: true)
} else {
self.logger.error("Failed to remove quarantine for \(path): \(output)")
continuation.resume(returning: false)
}
}
}
}

private func connect() -> NSXPCConnection {
if let connection = self.connection {
return connection
}

// Though basically undocumented, System Extensions can communicate with
// LaunchDaemons over XPC if the machServiceName used is prefixed with
// the team identifier.
// https://developer.apple.com/forums/thread/654466
let connection = NSXPCConnection(
machServiceName: "4399GN35BJ.com.coder.Coder-Desktop.Helper",
options: .privileged
)
connection.remoteObjectInterface = NSXPCInterface(with: HelperXPCProtocol.self)
connection.invalidationHandler = { [weak self] in
self?.connection = nil
}
connection.interruptionHandler = { [weak self] in
self?.connection = nil
}
connection.resume()
self.connection = connection
return connection
}
}
7 changes: 7 additions & 0 deletions Coder-Desktop/VPN/Manager.swift
Original file line number Diff line number Diff line change
@@ -312,7 +312,14 @@ private func removeQuarantine(_ dest: URL) async throws(ManagerError) {
let file = NSURL(fileURLWithPath: dest.path)
try? file.getResourceValue(&flag, forKey: kCFURLQuarantinePropertiesKey as URLResourceKey)
if flag != nil {
// Try the privileged helper first (it may not even be registered)
if await globalHelperXPCSpeaker.tryRemoveQuarantine(path: dest.path) {
// Success!
return
}
// Then try the app
guard let conn = globalXPCListenerDelegate.conn else {
// If neither are available, we can't execute the dylib
throw .noApp
}
// Wait for unsandboxed app to accept our file
43 changes: 3 additions & 40 deletions Coder-Desktop/VPN/main.swift
Original file line number Diff line number Diff line change
@@ -5,45 +5,6 @@ import VPNLib

let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "provider")

final class XPCListenerDelegate: NSObject, NSXPCListenerDelegate, @unchecked Sendable {
let vpnXPCInterface = XPCInterface()
private var activeConnection: NSXPCConnection?
private var connMutex: NSLock = .init()

var conn: VPNXPCClientCallbackProtocol? {
connMutex.lock()
defer { connMutex.unlock() }

let conn = activeConnection?.remoteObjectProxy as? VPNXPCClientCallbackProtocol
return conn
}

func setActiveConnection(_ connection: NSXPCConnection?) {
connMutex.lock()
defer { connMutex.unlock() }
activeConnection = connection
}

func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
newConnection.exportedInterface = NSXPCInterface(with: VPNXPCProtocol.self)
newConnection.exportedObject = vpnXPCInterface
newConnection.remoteObjectInterface = NSXPCInterface(with: VPNXPCClientCallbackProtocol.self)
newConnection.invalidationHandler = { [weak self] in
logger.info("active connection dead")
self?.setActiveConnection(nil)
}
newConnection.interruptionHandler = { [weak self] in
logger.debug("connection interrupted")
self?.setActiveConnection(nil)
}
logger.info("new active connection")
setActiveConnection(newConnection)

newConnection.resume()
return true
}
}

guard
let netExt = Bundle.main.object(forInfoDictionaryKey: "NetworkExtension") as? [String: Any],
let serviceName = netExt["NEMachServiceName"] as? String
@@ -57,9 +18,11 @@ autoreleasepool {
NEProvider.startSystemExtensionMode()
}

let globalXPCListenerDelegate = XPCListenerDelegate()
let globalXPCListenerDelegate = AppXPCListener()
let xpcListener = NSXPCListener(machServiceName: serviceName)
xpcListener.delegate = globalXPCListenerDelegate
xpcListener.resume()

let globalHelperXPCSpeaker = HelperXPCSpeaker()

dispatchMain()
2 changes: 1 addition & 1 deletion Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
Original file line number Diff line number Diff line change
@@ -32,7 +32,7 @@ public class MutagenDaemon: FileSyncDaemon {

@Published public var state: DaemonState = .stopped {
didSet {
logger.info("daemon state set: \(self.state.description, privacy: .public)")
logger.info("mutagen daemon state set: \(self.state.description, privacy: .public)")
if case .failed = state {
Task {
try? await cleanupGRPC()
25 changes: 25 additions & 0 deletions Coder-Desktop/project.yml
Original file line number Diff line number Diff line change
@@ -139,6 +139,13 @@ targets:
- path: Coder-Desktop
- path: Resources
buildPhase: resources
- path: Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist
attributes:
- CodeSignOnCopy
buildPhase:
copyFiles:
destination: wrapper
subpath: Contents/Library/LaunchDaemons
entitlements:
path: Coder-Desktop/Coder-Desktop.entitlements
properties:
@@ -185,6 +192,11 @@ targets:
embed: false # Loaded from SE bundle
- target: VPN
embed: without-signing # Embed without signing.
- target: Coder-DesktopHelper
embed: true
codeSign: true
copy:
destination: executables
- package: FluidMenuBarExtra
- package: KeychainAccess
- package: LaunchAtLogin
@@ -235,6 +247,7 @@ targets:
platform: macOS
sources:
- path: VPN
- path: Coder-DesktopHelper/HelperXPCProtocol.swift
Copy link
Member Author

@ethanndickson ethanndickson May 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file with the XPC protocol is included in both targets, to avoid having it defined twice.

entitlements:
path: VPN/VPN.entitlements
properties:
@@ -347,3 +360,15 @@ targets:
base:
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Coder Desktop.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Coder Desktop"
PRODUCT_BUNDLE_IDENTIFIER: com.coder.Coder-Desktop.CoderSDKTests

Coder-DesktopHelper:
type: tool
platform: macOS
sources: Coder-DesktopHelper
settings:
base:
ENABLE_HARDENED_RUNTIME: YES
PRODUCT_BUNDLE_IDENTIFIER: "com.coder.Coder-Desktop.Helper"
PRODUCT_MODULE_NAME: "$(PRODUCT_NAME:c99extidentifier)"
PRODUCT_NAME: "$(PRODUCT_BUNDLE_IDENTIFIER)"
SKIP_INSTALL: YES