diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift index 307e079..35aed08 100644 --- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift +++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift @@ -25,6 +25,7 @@ struct DesktopApp: App { SettingsView() .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 diff --git a/Coder-Desktop/Coder-Desktop/HelperService.swift b/Coder-Desktop/Coder-Desktop/HelperService.swift new file mode 100644 index 0000000..17bdc72 --- /dev/null +++ b/Coder-Desktop/Coder-Desktop/HelperService.swift @@ -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 + } + } +} diff --git a/Coder-Desktop/Coder-Desktop/Views/Settings/ExperimentalTab.swift b/Coder-Desktop/Coder-Desktop/Views/Settings/ExperimentalTab.swift new file mode 100644 index 0000000..838f458 --- /dev/null +++ b/Coder-Desktop/Coder-Desktop/Views/Settings/ExperimentalTab.swift @@ -0,0 +1,10 @@ +import LaunchAtLogin +import SwiftUI + +struct ExperimentalTab: View { + var body: some View { + Form { + HelperSection() + }.formStyle(.grouped) + } +} diff --git a/Coder-Desktop/Coder-Desktop/Views/Settings/HelperSection.swift b/Coder-Desktop/Coder-Desktop/Views/Settings/HelperSection.swift new file mode 100644 index 0000000..66fdc53 --- /dev/null +++ b/Coder-Desktop/Coder-Desktop/Views/Settings/HelperSection.swift @@ -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()) +} diff --git a/Coder-Desktop/Coder-Desktop/Views/Settings/Settings.swift b/Coder-Desktop/Coder-Desktop/Views/Settings/Settings.swift index 8aac9a0..170d171 100644 --- a/Coder-Desktop/Coder-Desktop/Views/Settings/Settings.swift +++ b/Coder-Desktop/Coder-Desktop/Views/Settings/Settings.swift @@ -13,6 +13,11 @@ struct SettingsView: 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: View { enum SettingsTab: Int { case general case network + case experimental } diff --git a/Coder-Desktop/Coder-Desktop/XPCInterface.swift b/Coder-Desktop/Coder-Desktop/XPCInterface.swift index 43c6f09..e21be86 100644 --- a/Coder-Desktop/Coder-Desktop/XPCInterface.swift +++ b/Coder-Desktop/Coder-Desktop/XPCInterface.swift @@ -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() } diff --git a/Coder-Desktop/Coder-DesktopHelper/HelperXPCProtocol.swift b/Coder-Desktop/Coder-DesktopHelper/HelperXPCProtocol.swift new file mode 100644 index 0000000..5ffed59 --- /dev/null +++ b/Coder-Desktop/Coder-DesktopHelper/HelperXPCProtocol.swift @@ -0,0 +1,5 @@ +import Foundation + +@objc protocol HelperXPCProtocol { + func removeQuarantine(path: String, withReply reply: @escaping (Int32, String) -> Void) +} diff --git a/Coder-Desktop/Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist b/Coder-Desktop/Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist new file mode 100644 index 0000000..c00eed4 --- /dev/null +++ b/Coder-Desktop/Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist @@ -0,0 +1,20 @@ + + + + + Label + com.coder.Coder-Desktop.Helper + BundleProgram + Contents/MacOS/com.coder.Coder-Desktop.Helper + MachServices + + + 4399GN35BJ.com.coder.Coder-Desktop.Helper + + + AssociatedBundleIdentifiers + + com.coder.Coder-Desktop + + + diff --git a/Coder-Desktop/Coder-DesktopHelper/main.swift b/Coder-Desktop/Coder-DesktopHelper/main.swift new file mode 100644 index 0000000..0e94af2 --- /dev/null +++ b/Coder-Desktop/Coder-DesktopHelper/main.swift @@ -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 + .resolvingSymlinksInPath() + + // *Must* be within the Coder Desktop System Extension sandbox + let requiredPrefix = ["/", "var", "root", "Library", "Containers", + "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() diff --git a/Coder-Desktop/VPN/AppXPCListener.swift b/Coder-Desktop/VPN/AppXPCListener.swift new file mode 100644 index 0000000..3d77f01 --- /dev/null +++ b/Coder-Desktop/VPN/AppXPCListener.swift @@ -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 + } +} diff --git a/Coder-Desktop/VPN/HelperXPCSpeaker.swift b/Coder-Desktop/VPN/HelperXPCSpeaker.swift new file mode 100644 index 0000000..77de1f3 --- /dev/null +++ b/Coder-Desktop/VPN/HelperXPCSpeaker.swift @@ -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() + 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 + } +} diff --git a/Coder-Desktop/VPN/Manager.swift b/Coder-Desktop/VPN/Manager.swift index b957381..bc441ac 100644 --- a/Coder-Desktop/VPN/Manager.swift +++ b/Coder-Desktop/VPN/Manager.swift @@ -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 diff --git a/Coder-Desktop/VPN/main.swift b/Coder-Desktop/VPN/main.swift index 708c2e0..bf6c371 100644 --- a/Coder-Desktop/VPN/main.swift +++ b/Coder-Desktop/VPN/main.swift @@ -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() diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift index 98807e3..d4b3606 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift @@ -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() diff --git a/Coder-Desktop/project.yml b/Coder-Desktop/project.yml index f2c96fa..9455a44 100644 --- a/Coder-Desktop/project.yml +++ b/Coder-Desktop/project.yml @@ -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 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 \ No newline at end of file