From 42b8f0bc9751aa6a47d5c44650d0b2ec60933bbf Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 30 Jul 2025 19:30:16 +1000 Subject: [PATCH] chore: use slim binary over dylib --- Coder-Desktop/.swiftlint.yml | 2 + .../Coder-Desktop/Views/LoginForm.swift | 2 +- .../Coder-DesktopHelper/Manager.swift | 136 ++++++++++----- .../Coder-DesktopHelper/TunnelHandle.swift | 116 ------------- ..._coder_Coder_Desktop_VPN-Bridging-Header.h | 7 - .../Coder-DesktopTests/LoginFormTests.swift | 2 +- Coder-Desktop/VPNLib/Download.swift | 2 +- Coder-Desktop/VPNLib/Receiver.swift | 2 +- Coder-Desktop/VPNLib/Speaker.swift | 4 +- Coder-Desktop/VPNLib/System.swift | 80 +++++++++ Coder-Desktop/VPNLib/TunnelDaemon.swift | 161 ++++++++++++++++++ Coder-Desktop/VPNLib/Validate.swift | 61 +------ .../VPNLibTests/TunnelDaemonTests.swift | 160 +++++++++++++++++ Coder-Desktop/project.yml | 1 - scripts/update-cask.sh | 40 +++-- 15 files changed, 530 insertions(+), 246 deletions(-) delete mode 100644 Coder-Desktop/Coder-DesktopHelper/TunnelHandle.swift delete mode 100644 Coder-Desktop/Coder-DesktopHelper/com_coder_Coder_Desktop_VPN-Bridging-Header.h create mode 100644 Coder-Desktop/VPNLib/System.swift create mode 100644 Coder-Desktop/VPNLib/TunnelDaemon.swift create mode 100644 Coder-Desktop/VPNLibTests/TunnelDaemonTests.swift diff --git a/Coder-Desktop/.swiftlint.yml b/Coder-Desktop/.swiftlint.yml index 1cf2d055..9085646f 100644 --- a/Coder-Desktop/.swiftlint.yml +++ b/Coder-Desktop/.swiftlint.yml @@ -10,3 +10,5 @@ type_name: identifier_name: allowed_symbols: "_" min_length: 1 +line_length: + ignores_urls: true diff --git a/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift b/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift index c2374f6a..04f157b1 100644 --- a/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift +++ b/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift @@ -222,7 +222,7 @@ enum LoginError: Error { case .outdatedCoderVersion: """ The Coder deployment must be version \(Validator.minimumCoderVersion) - or higher to use Coder Desktop. + or higher to use this version of Coder Desktop. """ case let .failedAuth(err): "Could not authenticate with Coder deployment:\n\(err.localizedDescription)" diff --git a/Coder-Desktop/Coder-DesktopHelper/Manager.swift b/Coder-Desktop/Coder-DesktopHelper/Manager.swift index 07a21ed9..aada7b25 100644 --- a/Coder-Desktop/Coder-DesktopHelper/Manager.swift +++ b/Coder-Desktop/Coder-DesktopHelper/Manager.swift @@ -7,26 +7,56 @@ actor Manager { let cfg: ManagerConfig let telemetryEnricher: TelemetryEnricher - let tunnelHandle: TunnelHandle + let tunnelDaemon: TunnelDaemon let speaker: Speaker var readLoop: Task! - // /var/root/Downloads - private let dest = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask) - .first!.appending(path: "coder-vpn.dylib") + #if arch(arm64) + private static let binaryName = "coder-darwin-arm64" + #else + private static let binaryName = "coder-darwin-amd64" + #endif + + // /var/root/Library/Application Support/com.coder.Coder-Desktop/coder-darwin-{arm64,amd64} + private let dest = try? FileManager.default + .url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + .appendingPathComponent(Bundle.main.bundleIdentifier ?? "com.coder.Coder-Desktop", isDirectory: true) + .appendingPathComponent(binaryName) + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "manager") // swiftlint:disable:next function_body_length init(cfg: ManagerConfig) async throws(ManagerError) { self.cfg = cfg telemetryEnricher = TelemetryEnricher() - #if arch(arm64) - let dylibPath = cfg.serverUrl.appending(path: "bin/coder-vpn-darwin-arm64.dylib") - #elseif arch(x86_64) - let dylibPath = cfg.serverUrl.appending(path: "bin/coder-vpn-darwin-amd64.dylib") - #else - fatalError("unknown architecture") - #endif + guard let dest else { + // This should never happen + throw .fileError("Failed to create path for binary destination" + + "(/var/root/Library/Application Support/com.coder.Coder-Desktop)") + } + do { + try FileManager.default.ensureDirectories(for: dest) + } catch { + throw .fileError( + "Failed to create directories for binary destination (\(dest)): \(error.localizedDescription)" + ) + } + let client = Client(url: cfg.serverUrl) + let buildInfo: BuildInfoResponse + do { + buildInfo = try await client.buildInfo() + } catch { + throw .serverInfo(error.description) + } + guard let serverSemver = buildInfo.semver else { + throw .serverInfo("invalid version: \(buildInfo.version)") + } + guard Validator.minimumCoderVersion + .compare(serverSemver, options: .numeric) != .orderedDescending + else { + throw .belowMinimumCoderVersion(actualVersion: serverSemver) + } + let binaryPath = cfg.serverUrl.appending(path: "bin").appending(path: Manager.binaryName) do { let sessionConfig = URLSessionConfiguration.default // The tunnel might be asked to start before the network interfaces have woken up from sleep @@ -35,7 +65,7 @@ actor Manager { sessionConfig.timeoutIntervalForRequest = 60 sessionConfig.timeoutIntervalForResource = 300 try await download( - src: dylibPath, + src: binaryPath, dest: dest, urlSession: URLSession(configuration: sessionConfig) ) { progress in @@ -45,48 +75,46 @@ actor Manager { throw .download(error) } pushProgress(stage: .validating) - let client = Client(url: cfg.serverUrl) - let buildInfo: BuildInfoResponse do { - buildInfo = try await client.buildInfo() + try Validator.validate(path: dest) } catch { - throw .serverInfo(error.description) - } - guard let semver = buildInfo.semver else { - throw .serverInfo("invalid version: \(buildInfo.version)") + // Cleanup unvalid binary + try? FileManager.default.removeItem(at: dest) + throw .validation(error) } + + // Without this, the TUN fd isn't recognised as a socket in the + // spawned process, and the tunnel fails to start. do { - try Validator.validate(path: dest, expectedVersion: semver) + try unsetCloseOnExec(fd: cfg.tunFd) } catch { - throw .validation(error) + throw .cloexec(error) } do { - try tunnelHandle = TunnelHandle(dylibPath: dest) + try tunnelDaemon = await TunnelDaemon(binaryPath: dest) { err in + Task { try? await NEXPCServerDelegate.cancelProvider(error: + makeNSError(suffix: "TunnelDaemon", desc: "Tunnel daemon: \(err.description)") + ) } + } } catch { throw .tunnelSetup(error) } speaker = await Speaker( - writeFD: tunnelHandle.writeHandle, - readFD: tunnelHandle.readHandle + writeFD: tunnelDaemon.writeHandle, + readFD: tunnelDaemon.readHandle ) do { try await speaker.handshake() } catch { throw .handshake(error) } - do { - try await tunnelHandle.openTunnelTask?.value - } catch let error as TunnelHandleError { - logger.error("failed to wait for dylib to open tunnel: \(error, privacy: .public) ") - throw .tunnelSetup(error) - } catch { - fatalError("openTunnelTask must only throw TunnelHandleError") - } readLoop = Task { try await run() } } + deinit { logger.debug("manager deinit") } + func run() async throws { do { for try await m in speaker { @@ -99,14 +127,14 @@ actor Manager { } } catch { logger.error("tunnel read loop failed: \(error.localizedDescription, privacy: .public)") - try await tunnelHandle.close() + try await tunnelDaemon.close() try await NEXPCServerDelegate.cancelProvider(error: makeNSError(suffix: "Manager", desc: "Tunnel read loop failed: \(error.localizedDescription)") ) return } logger.info("tunnel read loop exited") - try await tunnelHandle.close() + try await tunnelDaemon.close() try await NEXPCServerDelegate.cancelProvider(error: nil) } @@ -204,6 +232,12 @@ actor Manager { if !stopResp.success { throw .errorResponse(msg: stopResp.errorMessage) } + do { + try await tunnelDaemon.close() + } catch { + throw .tunnelFail(error) + } + readLoop.cancel() } // Retrieves the current state of all peers, @@ -239,28 +273,32 @@ struct ManagerConfig { enum ManagerError: Error { case download(DownloadError) - case tunnelSetup(TunnelHandleError) + case fileError(String) + case tunnelSetup(TunnelDaemonError) case handshake(HandshakeError) case validation(ValidationError) case incorrectResponse(Vpn_TunnelMessage) + case cloexec(POSIXError) case failedRPC(any Error) case serverInfo(String) case errorResponse(msg: String) - case noTunnelFileDescriptor - case noApp - case permissionDenied case tunnelFail(any Error) + case belowMinimumCoderVersion(actualVersion: String) var description: String { switch self { case let .download(err): "Download error: \(err.localizedDescription)" + case let .fileError(msg): + msg case let .tunnelSetup(err): "Tunnel setup error: \(err.localizedDescription)" case let .handshake(err): "Handshake error: \(err.localizedDescription)" case let .validation(err): "Validation error: \(err.localizedDescription)" + case let .cloexec(err): + "Failed to mark TUN fd as non-cloexec: \(err.localizedDescription)" case .incorrectResponse: "Received unexpected response over tunnel" case let .failedRPC(err): @@ -269,14 +307,13 @@ enum ManagerError: Error { msg case let .errorResponse(msg): msg - case .noTunnelFileDescriptor: - "Could not find a tunnel file descriptor" - case .noApp: - "The VPN must be started with the app open during first-time setup." - case .permissionDenied: - "Permission was not granted to execute the CoderVPN dylib" case let .tunnelFail(err): - "Failed to communicate with dylib over tunnel: \(err.localizedDescription)" + "Failed to communicate with daemon over tunnel: \(err.localizedDescription)" + case let .belowMinimumCoderVersion(actualVersion): + """ + The Coder deployment must be version \(Validator.minimumCoderVersion) + or higher to use Coder Desktop. Current version: \(actualVersion) + """ } } @@ -297,9 +334,16 @@ func writeVpnLog(_ log: Vpn_Log) { case .UNRECOGNIZED: .info } let logger = Logger( - subsystem: "\(Bundle.main.bundleIdentifier!).dylib", + subsystem: "\(Bundle.main.bundleIdentifier!).daemon", category: log.loggerNames.joined(separator: ".") ) let fields = log.fields.map { "\($0.name): \($0.value)" }.joined(separator: ", ") logger.log(level: level, "\(log.message, privacy: .public)\(fields.isEmpty ? "" : ": \(fields)", privacy: .public)") } + +extension FileManager { + func ensureDirectories(for url: URL) throws { + let dir = url.hasDirectoryPath ? url : url.deletingLastPathComponent() + try createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) + } +} diff --git a/Coder-Desktop/Coder-DesktopHelper/TunnelHandle.swift b/Coder-Desktop/Coder-DesktopHelper/TunnelHandle.swift deleted file mode 100644 index 425a0ccb..00000000 --- a/Coder-Desktop/Coder-DesktopHelper/TunnelHandle.swift +++ /dev/null @@ -1,116 +0,0 @@ -import Foundation -import os - -let startSymbol = "OpenTunnel" - -actor TunnelHandle { - private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "tunnel-handle") - - private let tunnelWritePipe: Pipe - private let tunnelReadPipe: Pipe - private let dylibHandle: UnsafeMutableRawPointer - - var writeHandle: FileHandle { tunnelReadPipe.fileHandleForWriting } - var readHandle: FileHandle { tunnelWritePipe.fileHandleForReading } - - // MUST only ever throw TunnelHandleError - var openTunnelTask: Task? - - init(dylibPath: URL) throws(TunnelHandleError) { - guard let dylibHandle = dlopen(dylibPath.path, RTLD_NOW | RTLD_LOCAL) else { - throw .dylib(dlerror().flatMap { String(cString: $0) } ?? "UNKNOWN") - } - self.dylibHandle = dylibHandle - - guard let startSym = dlsym(dylibHandle, startSymbol) else { - throw .symbol(startSymbol, dlerror().flatMap { String(cString: $0) } ?? "UNKNOWN") - } - let openTunnelFn = SendableOpenTunnel(unsafeBitCast(startSym, to: OpenTunnel.self)) - tunnelReadPipe = Pipe() - tunnelWritePipe = Pipe() - let rfd = tunnelReadPipe.fileHandleForReading.fileDescriptor - let wfd = tunnelWritePipe.fileHandleForWriting.fileDescriptor - openTunnelTask = Task { [openTunnelFn] in - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - DispatchQueue.global().async { - let res = openTunnelFn(rfd, wfd) - guard res == 0 else { - cont.resume(throwing: TunnelHandleError.openTunnel(OpenTunnelError(rawValue: res) ?? .unknown)) - return - } - cont.resume() - } - } - } - } - - // This could be an isolated deinit in Swift 6.1 - func close() throws(TunnelHandleError) { - var errs: [Error] = [] - if dlclose(dylibHandle) == 0 { - errs.append(TunnelHandleError.dylib(dlerror().flatMap { String(cString: $0) } ?? "UNKNOWN")) - } - do { - try writeHandle.close() - } catch { - errs.append(error) - } - do { - try readHandle.close() - } catch { - errs.append(error) - } - if !errs.isEmpty { - throw .close(errs) - } - } -} - -enum TunnelHandleError: Error { - case dylib(String) - case symbol(String, String) - case openTunnel(OpenTunnelError) - case pipe(any Error) - case close([any Error]) - - var description: String { - switch self { - case let .pipe(err): "pipe error: \(err.localizedDescription)" - case let .dylib(d): d - case let .symbol(symbol, message): "\(symbol): \(message)" - case let .openTunnel(error): "OpenTunnel: \(error.message)" - case let .close(errs): "close tunnel: \(errs.map(\.localizedDescription).joined(separator: ", "))" - } - } - - var localizedDescription: String { description } -} - -enum OpenTunnelError: Int32 { - case errDupReadFD = -2 - case errDupWriteFD = -3 - case errOpenPipe = -4 - case errNewTunnel = -5 - case unknown = -99 - - var message: String { - switch self { - case .errDupReadFD: "Failed to duplicate read file descriptor" - case .errDupWriteFD: "Failed to duplicate write file descriptor" - case .errOpenPipe: "Failed to open the pipe" - case .errNewTunnel: "Failed to create a new tunnel" - case .unknown: "Unknown error code" - } - } -} - -struct SendableOpenTunnel: @unchecked Sendable { - let fn: OpenTunnel - init(_ function: OpenTunnel) { - fn = function - } - - func callAsFunction(_ lhs: Int32, _ rhs: Int32) -> Int32 { - fn(lhs, rhs) - } -} diff --git a/Coder-Desktop/Coder-DesktopHelper/com_coder_Coder_Desktop_VPN-Bridging-Header.h b/Coder-Desktop/Coder-DesktopHelper/com_coder_Coder_Desktop_VPN-Bridging-Header.h deleted file mode 100644 index 6c8e5b48..00000000 --- a/Coder-Desktop/Coder-DesktopHelper/com_coder_Coder_Desktop_VPN-Bridging-Header.h +++ /dev/null @@ -1,7 +0,0 @@ -#ifndef CoderPacketTunnelProvider_Bridging_Header_h -#define CoderPacketTunnelProvider_Bridging_Header_h - -// GoInt32 OpenTunnel(GoInt32 cReadFD, GoInt32 cWriteFD); -typedef int(*OpenTunnel)(int, int); - -#endif /* CoderPacketTunnelProvider_Bridging_Header_h */ diff --git a/Coder-Desktop/Coder-DesktopTests/LoginFormTests.swift b/Coder-Desktop/Coder-DesktopTests/LoginFormTests.swift index 24ab1f0f..17a02648 100644 --- a/Coder-Desktop/Coder-DesktopTests/LoginFormTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/LoginFormTests.swift @@ -134,7 +134,7 @@ struct LoginTests { username: "admin" ) let buildInfo = BuildInfoResponse( - version: "v2.20.0" + version: "v2.24.2" ) try Mock( diff --git a/Coder-Desktop/VPNLib/Download.swift b/Coder-Desktop/VPNLib/Download.swift index 16a92032..37c53ec5 100644 --- a/Coder-Desktop/VPNLib/Download.swift +++ b/Coder-Desktop/VPNLib/Download.swift @@ -102,7 +102,7 @@ extension DownloadManager: URLSessionDownloadDelegate { return } guard httpResponse.statusCode != 304 else { - // We already have the latest dylib downloaded in dest + // We already have the latest binary downloaded in dest continuation.resume() return } diff --git a/Coder-Desktop/VPNLib/Receiver.swift b/Coder-Desktop/VPNLib/Receiver.swift index 699d46f3..b5129ab8 100644 --- a/Coder-Desktop/VPNLib/Receiver.swift +++ b/Coder-Desktop/VPNLib/Receiver.swift @@ -69,7 +69,7 @@ actor Receiver { }, onCancel: { self.logger.debug("async stream canceled") - self.dispatch.close() + self.dispatch.close(flags: [.stop]) } ) } diff --git a/Coder-Desktop/VPNLib/Speaker.swift b/Coder-Desktop/VPNLib/Speaker.swift index 88e46b05..74597b1c 100644 --- a/Coder-Desktop/VPNLib/Speaker.swift +++ b/Coder-Desktop/VPNLib/Speaker.swift @@ -86,6 +86,8 @@ public actor Speaker pid_t { + var pid: pid_t = 0 + + // argv = [executable, args..., nil] + var argv: [UnsafeMutablePointer?] = [] + argv.append(strdup(executable)) + for a in args { + argv.append(strdup(a)) + } + argv.append(nil) + defer { for p in argv where p != nil { + free(p) + } } + + let rc: Int32 = argv.withUnsafeMutableBufferPointer { argvBuf in + posix_spawn(&pid, executable, nil, nil, argvBuf.baseAddress, nil) + } + if rc != 0 { + throw .spawn(POSIXError(POSIXErrorCode(rawValue: rc) ?? .EPERM)) + } + return pid +} + +public func unsetCloseOnExec(fd: Int32) throws(POSIXError) { + let cur = fcntl(fd, F_GETFD) + guard cur != -1 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EPERM) + } + let newFlags: Int32 = (cur & ~FD_CLOEXEC) + guard fcntl(fd, F_SETFD, newFlags) != -1 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EPERM) + } +} + +public func chmodX(at url: URL) throws(POSIXError) { + var st = stat() + guard stat(url.path, &st) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EPERM) + } + + let newMode: mode_t = st.st_mode | mode_t(S_IXUSR | S_IXGRP | S_IXOTH) + + guard chmod(url.path, newMode) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EPERM) + } +} + +// SPDX-License-Identifier: Apache-2.0 WITH Swift-exception +// +// Derived from swiftlang/swift-subprocess +// Original: https://github.com/swiftlang/swift-subprocess/blob/7fb7ee86df8ca4f172697bfbafa89cdc583ac016/Sources/Subprocess/Platforms/Subprocess%2BDarwin.swift#L487-L525 +// Copyright (c) 2025 Apple Inc. and the Swift project authors +@Sendable public func monitorProcessTermination(pid: pid_t) async throws -> Termination { + try await withCheckedThrowingContinuation { continuation in + let source = DispatchSource.makeProcessSource( + identifier: pid, + eventMask: [.exit], + queue: .global() + ) + source.setEventHandler { + source.cancel() + var siginfo = siginfo_t() + let rc = waitid(P_PID, id_t(pid), &siginfo, WEXITED) + guard rc == 0 else { + let err = POSIXError(POSIXErrorCode(rawValue: errno) ?? .EINTR) + continuation.resume(throwing: err) + return + } + switch siginfo.si_code { + case .init(CLD_EXITED): + continuation.resume(returning: .exited(siginfo.si_status)) + case .init(CLD_KILLED), .init(CLD_DUMPED): + continuation.resume(returning: .unhandledException(siginfo.si_status)) + default: + continuation.resume(returning: .unhandledException(siginfo.si_status)) + } + } + source.resume() + } +} diff --git a/Coder-Desktop/VPNLib/TunnelDaemon.swift b/Coder-Desktop/VPNLib/TunnelDaemon.swift new file mode 100644 index 00000000..9797d0e4 --- /dev/null +++ b/Coder-Desktop/VPNLib/TunnelDaemon.swift @@ -0,0 +1,161 @@ +import Darwin +import Foundation +import os + +public actor TunnelDaemon { + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TunnelDaemon") + private let tunnelWritePipe: Pipe + private let tunnelReadPipe: Pipe + private(set) var state: TunnelDaemonState = .stopped { + didSet { + if case let .failed(err) = state { + onFail(err) + } + } + } + + private var monitorTask: Task? + private var onFail: (TunnelDaemonError) -> Void + + public var writeHandle: FileHandle { tunnelReadPipe.fileHandleForWriting } + public var readHandle: FileHandle { tunnelWritePipe.fileHandleForReading } + + var pid: pid_t? + + public init(binaryPath: URL, onFail: @escaping (TunnelDaemonError) -> Void) async throws(TunnelDaemonError) { + self.onFail = onFail + tunnelReadPipe = Pipe() + tunnelWritePipe = Pipe() + let rfd = tunnelReadPipe.fileHandleForReading.fileDescriptor + let wfd = tunnelWritePipe.fileHandleForWriting.fileDescriptor + + // Not necessary, but can't hurt. + do { + try unsetCloseOnExec(fd: rfd) + try unsetCloseOnExec(fd: wfd) + } catch { + throw .cloexec(error) + } + + // Ensure the binary is executable. + do { + try chmodX(at: binaryPath) + } catch { + throw .chmod(error) + } + + let childPID = try spawn( + executable: binaryPath.path, + args: ["vpn-daemon", "run", + "--rpc-read-fd", String(rfd), + "--rpc-write-fd", String(wfd)] + ) + pid = childPID + state = .running + + monitorTask = Task { [weak self] in + guard let self else { return } + do { + let term = try await monitorProcessTermination(pid: childPID) + await onTermination(term) + } catch { + logger.error("failed to monitor daemon termination: \(error.localizedDescription)") + await setFailed(.monitoringFailed(error)) + } + } + } + + deinit { logger.debug("tunnel daemon deinit") } + + // This could be an isolated deinit in Swift 6.1 + public func close() throws(TunnelDaemonError) { + state = .stopped + + monitorTask?.cancel() + monitorTask = nil + + if let pid { + if kill(pid, SIGTERM) != 0, errno != ESRCH { + throw .close(POSIXError(POSIXErrorCode(rawValue: errno) ?? .EINTR)) + } else { + var info = siginfo_t() + _ = waitid(P_PID, id_t(pid), &info, WEXITED | WNOHANG) + } + } + + // Closing the Pipe FileHandles here manually results in a process crash: + // "BUG IN CLIENT OF LIBDISPATCH: Unexpected EV_VANISHED + // (do not destroy random mach ports or file descriptors)" + // I've manually verified that the file descriptors are closed when the + // `Manager` is deallocated (when `globalManager` is set to `nil`). + } + + private func setFailed(_ err: TunnelDaemonError) { + state = .failed(err) + } + + private func onTermination(_ termination: Termination) async { + switch state { + case .stopped: + return + default: + setFailed(.terminated(termination)) + } + } +} + +public enum TunnelDaemonState: Sendable { + case running + case stopped + case failed(TunnelDaemonError) + case unavailable + + public var description: String { + switch self { + case .running: + "Running" + case .stopped: + "Stopped" + case let .failed(err): + "Failed: \(err.localizedDescription)" + case .unavailable: + "Unavailable" + } + } +} + +public enum Termination: Sendable { + case exited(Int32) + case unhandledException(Int32) + + var description: String { + switch self { + case let .exited(status): + "Process exited with status \(status)" + case let .unhandledException(status): + "Process terminated with unhandled exception status \(status)" + } + } +} + +public enum TunnelDaemonError: Error, Sendable { + case spawn(POSIXError) + case cloexec(POSIXError) + case chmod(POSIXError) + case terminated(Termination) + case monitoringFailed(any Error) + case close(any Error) + + public var description: String { + switch self { + case let .terminated(reason): "daemon terminated: \(reason.description)" + case let .spawn(err): "spawn daemon: \(err.localizedDescription)" + case let .cloexec(err): "unset close-on-exec: \(err.localizedDescription)" + case let .chmod(err): "change permissions: \(err.localizedDescription)" + case let .monitoringFailed(err): "monitoring daemon termination: \(err.localizedDescription)" + case let .close(err): "close tunnel: \(err.localizedDescription)" + } + } + + public var localizedDescription: String { description } +} diff --git a/Coder-Desktop/VPNLib/Validate.swift b/Coder-Desktop/VPNLib/Validate.swift index f663bcbe..f7e1c83e 100644 --- a/Coder-Desktop/VPNLib/Validate.swift +++ b/Coder-Desktop/VPNLib/Validate.swift @@ -4,12 +4,10 @@ public enum ValidationError: Error { case fileNotFound case unableToCreateStaticCode case invalidSignature - case unableToRetrieveInfo + case unableToRetrieveSignature case invalidIdentifier(identifier: String?) case invalidTeamIdentifier(identifier: String?) - case missingInfoPList case invalidVersion(version: String?) - case belowMinimumCoderVersion public var description: String { switch self { @@ -19,7 +17,7 @@ public enum ValidationError: Error { "Unable to create a static code object." case .invalidSignature: "The file's signature is invalid." - case .unableToRetrieveInfo: + case .unableToRetrieveSignature: "Unable to retrieve signing information." case let .invalidIdentifier(identifier): "Invalid identifier: \(identifier ?? "unknown")." @@ -27,13 +25,6 @@ public enum ValidationError: Error { "Invalid runtime version: \(version ?? "unknown")." case let .invalidTeamIdentifier(identifier): "Invalid team identifier: \(identifier ?? "unknown")." - case .missingInfoPList: - "Info.plist is not embedded within the dylib." - case .belowMinimumCoderVersion: - """ - The Coder deployment must be version \(Validator.minimumCoderVersion) - or higher to use Coder Desktop. - """ } } @@ -41,21 +32,16 @@ public enum ValidationError: Error { } public class Validator { - // Whilst older dylibs exist, this app assumes v2.20 or later. - public static let minimumCoderVersion = "2.20.0" + // This version of the app has a strict version requirement. + // TODO(ethanndickson): Set to 2.25.0 + public static let minimumCoderVersion = "2.24.2" - private static let expectedName = "CoderVPN" - private static let expectedIdentifier = "com.coder.Coder-Desktop.VPN.dylib" + private static let expectedIdentifier = "com.coder.cli" private static let expectedTeamIdentifier = "4399GN35BJ" - private static let infoIdentifierKey = "CFBundleIdentifier" - private static let infoNameKey = "CFBundleName" - private static let infoShortVersionKey = "CFBundleShortVersionString" - private static let signInfoFlags: SecCSFlags = .init(rawValue: kSecCSSigningInformation) - // `expectedVersion` must be of the form `[0-9]+.[0-9]+.[0-9]+` - public static func validate(path: URL, expectedVersion: String) throws(ValidationError) { + public static func validate(path: URL) throws(ValidationError) { guard FileManager.default.fileExists(atPath: path.path) else { throw .fileNotFound } @@ -74,7 +60,7 @@ public class Validator { var information: CFDictionary? let infoStatus = SecCodeCopySigningInformation(code, signInfoFlags, &information) guard infoStatus == errSecSuccess, let info = information as? [String: Any] else { - throw .unableToRetrieveInfo + throw .unableToRetrieveSignature } guard let identifier = info[kSecCodeInfoIdentifier as String] as? String, @@ -90,39 +76,8 @@ public class Validator { identifier: info[kSecCodeInfoTeamIdentifier as String] as? String ) } - - guard let infoPlist = info[kSecCodeInfoPList as String] as? [String: AnyObject] else { - throw .missingInfoPList - } - - try validateInfo(infoPlist: infoPlist, expectedVersion: expectedVersion) } public static let xpcPeerRequirement = "anchor apple generic" + // Apple-issued certificate chain " and certificate leaf[subject.OU] = \"" + expectedTeamIdentifier + "\"" // Signed by the Coder team - - private static func validateInfo(infoPlist: [String: AnyObject], expectedVersion: String) throws(ValidationError) { - guard let plistIdent = infoPlist[infoIdentifierKey] as? String, plistIdent == expectedIdentifier else { - throw .invalidIdentifier(identifier: infoPlist[infoIdentifierKey] as? String) - } - - guard let plistName = infoPlist[infoNameKey] as? String, plistName == expectedName else { - throw .invalidIdentifier(identifier: infoPlist[infoNameKey] as? String) - } - - // Downloaded dylib must match the version of the server - guard let dylibVersion = infoPlist[infoShortVersionKey] as? String, - expectedVersion == dylibVersion - else { - throw .invalidVersion(version: infoPlist[infoShortVersionKey] as? String) - } - - // Downloaded dylib must be at least the minimum Coder server version - guard let dylibVersion = infoPlist[infoShortVersionKey] as? String, - // x.compare(y) is .orderedDescending if x > y - minimumCoderVersion.compare(dylibVersion, options: .numeric) != .orderedDescending - else { - throw .belowMinimumCoderVersion - } - } } diff --git a/Coder-Desktop/VPNLibTests/TunnelDaemonTests.swift b/Coder-Desktop/VPNLibTests/TunnelDaemonTests.swift new file mode 100644 index 00000000..ac1861e6 --- /dev/null +++ b/Coder-Desktop/VPNLibTests/TunnelDaemonTests.swift @@ -0,0 +1,160 @@ +import Foundation +import Testing +@testable import VPNLib + +@Suite(.timeLimit(.minutes(1))) +struct TunnelDaemonTests { + func createTempExecutable(content: String) throws -> URL { + let tempDir = FileManager.default.temporaryDirectory + let executableURL = tempDir.appendingPathComponent("test_daemon_\(UUID().uuidString)") + + try content.write(to: executableURL, atomically: true, encoding: .utf8) + // We purposefully don't mark as executable + return executableURL + } + + @Test func daemonStarts() async throws { + let longRunningScript = """ + #!/bin/bash + sleep 10 + """ + + let executableURL = try createTempExecutable(content: longRunningScript) + defer { try? FileManager.default.removeItem(at: executableURL) } + + var failureCalled = false + let daemon = try await TunnelDaemon(binaryPath: executableURL) { _ in + failureCalled = true + } + + await #expect(daemon.state.isRunning) + #expect(!failureCalled) + await #expect(daemon.readHandle.fileDescriptor >= 0) + await #expect(daemon.writeHandle.fileDescriptor >= 0) + + try await daemon.close() + await #expect(daemon.state.isStopped) + } + + @Test func daemonHandlesFailure() async throws { + let immediateExitScript = """ + #!/bin/bash + exit 1 + """ + + let executableURL = try createTempExecutable(content: immediateExitScript) + defer { try? FileManager.default.removeItem(at: executableURL) } + + var capturedError: TunnelDaemonError? + let daemon = try await TunnelDaemon(binaryPath: executableURL) { error in + capturedError = error + } + + #expect(await eventually(timeout: .milliseconds(500), interval: .milliseconds(10)) { @MainActor in + capturedError != nil + }) + + if case let .terminated(termination) = capturedError { + if case let .exited(status) = termination { + #expect(status == 1) + } else { + Issue.record("Expected exited termination, got \(termination)") + } + } else { + Issue.record("Expected terminated error, got \(String(describing: capturedError))") + } + + await #expect(daemon.state.isFailed) + } + + @Test func daemonExternallyKilled() async throws { + let script = """ + #!/bin/bash + # Process that will be killed with SIGKILL + sleep 30 + """ + + let executableURL = try createTempExecutable(content: script) + defer { try? FileManager.default.removeItem(at: executableURL) } + + var capturedError: TunnelDaemonError? + let daemon = try await TunnelDaemon(binaryPath: executableURL) { error in + capturedError = error + } + + await #expect(daemon.state.isRunning) + + guard let pid = await daemon.pid else { + Issue.record("Daemon pid is nil") + return + } + + kill(pid, SIGKILL) + + #expect(await eventually(timeout: .milliseconds(500), interval: .milliseconds(10)) { @MainActor in + capturedError != nil + }) + + if case let .terminated(termination) = capturedError { + if case let .unhandledException(status) = termination { + #expect(status == SIGKILL) + } else { + Issue.record("Expected unhandledException termination, got \(termination)") + } + } else { + Issue.record("Expected terminated error, got \(String(describing: capturedError))") + } + } + + @Test func invalidBinaryPathThrowsError() async throws { + let nonExistentPath = URL(fileURLWithPath: "/this/path/does/not/exist/binary") + + await #expect(throws: TunnelDaemonError.self) { + _ = try await TunnelDaemon(binaryPath: nonExistentPath) { _ in } + } + } +} + +public func eventually( + timeout: Duration = .milliseconds(500), + interval: Duration = .milliseconds(10), + condition: @Sendable () async throws -> Bool +) async rethrows -> Bool { + let endTime = ContinuousClock.now.advanced(by: timeout) + + while ContinuousClock.now < endTime { + do { + if try await condition() { return true } + } catch { + try await Task.sleep(for: interval) + } + } + + return try await condition() +} + +extension TunnelDaemonState { + var isRunning: Bool { + if case .running = self { + true + } else { + false + } + } + + var isStopped: Bool { + if case .stopped = self { + true + } else { + false + } + } + + var isFailed: Bool { + if case .failed = self { + true + } else { + false + } + } +} diff --git a/Coder-Desktop/project.yml b/Coder-Desktop/project.yml index e5f48a63..6e7711ac 100644 --- a/Coder-Desktop/project.yml +++ b/Coder-Desktop/project.yml @@ -397,7 +397,6 @@ targets: settings: base: ENABLE_HARDENED_RUNTIME: YES - SWIFT_OBJC_BRIDGING_HEADER: "Coder-DesktopHelper/com_coder_Coder_Desktop_VPN-Bridging-Header.h" PRODUCT_BUNDLE_IDENTIFIER: "com.coder.Coder-Desktop.Helper" PRODUCT_MODULE_NAME: "$(PRODUCT_NAME:c99extidentifier)" PRODUCT_NAME: "$(PRODUCT_BUNDLE_IDENTIFIER)" diff --git a/scripts/update-cask.sh b/scripts/update-cask.sh index 7e2a1c5c..c72f59b5 100755 --- a/scripts/update-cask.sh +++ b/scripts/update-cask.sh @@ -14,23 +14,23 @@ ASSIGNEE="" # Parse command line arguments while [[ "$#" -gt 0 ]]; do case $1 in - --version) - VERSION="$2" - shift 2 - ;; - --assignee) - ASSIGNEE="$2" - shift 2 - ;; - -h | --help) - usage - exit 0 - ;; - *) - echo "Unknown parameter passed: $1" - usage - exit 1 - ;; + --version) + VERSION="$2" + shift 2 + ;; + --assignee) + ASSIGNEE="$2" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "Unknown parameter passed: $1" + usage + exit 1 + ;; esac done @@ -98,7 +98,11 @@ cask "coder-desktop" do ], login_item: "Coder Desktop" - zap delete: "/var/root/Library/Containers/com.Coder-Desktop.VPN/Data/Documents/coder-vpn.dylib", + zap delete: [ + "/var/root/Library/Application Support/com.coder.Coder-Desktop/coder-darwin-arm64", + "/var/root/Library/Application Support/com.coder.Coder-Desktop/coder-darwin_amd64", + "/var/root/Library/Containers/com.Coder-Desktop.VPN/Data/Documents/coder-vpn.dylib", + ], trash: [ "~/Library/Caches/com.coder.Coder-Desktop", "~/Library/HTTPStorages/com.coder.Coder-Desktop",