-
Notifications
You must be signed in to change notification settings - Fork 3
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
} | ||
} | ||
} |
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) | ||
} | ||
} |
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()) | ||
} |
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> |
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
.resolvingSymlinksInPath() | ||
|
||
// *Must* be within the Coder Desktop System Extension sandbox | ||
let requiredPrefix = ["/", "var", "root", "Library", "Containers", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
|
||
"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() |
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 | ||
} | ||
} |
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() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
} | ||
} |
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
Uh oh!
There was an error while loading. Please reload this page.