From 4bb3a24990126213635d56c9b06bbcb3a803169d Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 28 Jul 2025 17:50:37 +1000 Subject: [PATCH] fix: add toggle for Coder deployments behind a VPN --- Coder-Desktop/Coder-Desktop/State.swift | 28 +++++++++++++++---- .../Views/Settings/NetworkTab.swift | 19 +++++++++++++ .../HelperXPCListeners.swift | 3 ++ .../Coder-DesktopHelper/Manager.swift | 2 ++ Coder-Desktop/VPN/HelperXPCSpeaker.swift | 16 +++++++++-- Coder-Desktop/VPN/PacketTunnelProvider.swift | 16 +++++++---- Coder-Desktop/VPNLib/Configuration.swift | 9 ++++++ Coder-Desktop/VPNLib/Download.swift | 13 ++++++--- Coder-Desktop/VPNLib/XPC.swift | 12 ++++++-- Coder-Desktop/VPNLib/vpn.pb.swift | 8 ++++++ Coder-Desktop/VPNLib/vpn.proto | 1 + Coder-Desktop/project.yml | 2 +- 12 files changed, 109 insertions(+), 20 deletions(-) create mode 100644 Coder-Desktop/VPNLib/Configuration.swift diff --git a/Coder-Desktop/Coder-Desktop/State.swift b/Coder-Desktop/Coder-Desktop/State.swift index faf15e05..090b5a82 100644 --- a/Coder-Desktop/Coder-Desktop/State.swift +++ b/Coder-Desktop/Coder-Desktop/State.swift @@ -4,6 +4,7 @@ import KeychainAccess import NetworkExtension import os import SwiftUI +import VPNLib @MainActor class AppState: ObservableObject { @@ -70,6 +71,14 @@ class AppState: ObservableObject { } } + @Published var useSoftNetIsolation: Bool = UserDefaults.standard.bool(forKey: Keys.useSoftNetIsolation) { + didSet { + reconfigure() + guard persistent else { return } + UserDefaults.standard.set(useSoftNetIsolation, forKey: Keys.useSoftNetIsolation) + } + } + @Published var skipHiddenIconAlert: Bool = UserDefaults.standard.bool(forKey: Keys.skipHiddenIconAlert) { didSet { guard persistent else { return } @@ -81,11 +90,18 @@ class AppState: ObservableObject { if !hasSession { return nil } let proto = NETunnelProviderProtocol() proto.providerBundleIdentifier = "\(appId).VPN" - // HACK: We can't write to the system keychain, and the user keychain - // isn't accessible, so we'll use providerConfiguration, which is over XPC. - proto.providerConfiguration = ["token": sessionToken!] - if useLiteralHeaders, let headers = try? JSONEncoder().encode(literalHeaders) { - proto.providerConfiguration?["literalHeaders"] = headers + + proto.providerConfiguration = [ + // HACK: We can't write to the system keychain, and the user keychain + // isn't accessible, so we'll use providerConfiguration, which + // writes to disk. + VPNConfigurationKeys.token: sessionToken!, + VPNConfigurationKeys.useSoftNetIsolation: useSoftNetIsolation, + ] + if useLiteralHeaders { + proto.providerConfiguration?[ + VPNConfigurationKeys.literalHeaders + ] = literalHeaders.map { ($0.name, $0.value) } } proto.serverAddress = baseAccessURL!.absoluteString return proto @@ -188,6 +204,7 @@ class AppState: ObservableObject { } public func clearSession() { + logger.info("clearing session") hasSession = false sessionToken = nil refreshTask?.cancel() @@ -216,6 +233,7 @@ class AppState: ObservableObject { static let useLiteralHeaders = "UseLiteralHeaders" static let literalHeaders = "LiteralHeaders" + static let useSoftNetIsolation = "UseSoftNetIsolation" static let stopVPNOnQuit = "StopVPNOnQuit" static let startVPNOnLaunch = "StartVPNOnLaunch" diff --git a/Coder-Desktop/Coder-Desktop/Views/Settings/NetworkTab.swift b/Coder-Desktop/Coder-Desktop/Views/Settings/NetworkTab.swift index d830e74a..158f819a 100644 --- a/Coder-Desktop/Coder-Desktop/Views/Settings/NetworkTab.swift +++ b/Coder-Desktop/Coder-Desktop/Views/Settings/NetworkTab.swift @@ -4,11 +4,30 @@ struct NetworkTab: View { var body: some View { Form { LiteralHeadersSection() + SoftNetIsolationSection() } .formStyle(.grouped) } } +struct SoftNetIsolationSection: View { + @EnvironmentObject var state: AppState + @EnvironmentObject var vpn: VPN + var body: some View { + Section { + Toggle(isOn: $state.useSoftNetIsolation) { + Text("Enable support for corporate VPNs") + if !vpn.state.canBeStarted { Text("Cannot be modified while Coder Connect is enabled.") } + } + Text("This setting loosens the VPN loop protection in Coder Connect, allowing traffic to flow to a " + + "Coder deployment behind a corporate VPN. We only recommend enabling this option if Coder Connect " + + "doesn't work with your Coder deployment behind a corporate VPN.") + .font(.subheadline) + .foregroundStyle(.secondary) + }.disabled(!vpn.state.canBeStarted) + } +} + #if DEBUG #Preview { NetworkTab() diff --git a/Coder-Desktop/Coder-DesktopHelper/HelperXPCListeners.swift b/Coder-Desktop/Coder-DesktopHelper/HelperXPCListeners.swift index 32893602..27eabdf3 100644 --- a/Coder-Desktop/Coder-DesktopHelper/HelperXPCListeners.swift +++ b/Coder-Desktop/Coder-DesktopHelper/HelperXPCListeners.swift @@ -40,11 +40,13 @@ class HelperNEXPCListener: NSObject, NSXPCListenerDelegate, HelperNEXPCInterface let startSymbol = "OpenTunnel" + // swiftlint:disable:next function_parameter_count func startDaemon( accessURL: URL, token: String, tun: FileHandle, headers: Data?, + useSoftNetIsolation: Bool, reply: @escaping (Error?) -> Void ) { logger.info("startDaemon called") @@ -57,6 +59,7 @@ class HelperNEXPCListener: NSObject, NSXPCListenerDelegate, HelperNEXPCInterface apiToken: token, serverUrl: accessURL, tunFd: tun.fileDescriptor, + useSoftNetIsolation: useSoftNetIsolation, literalHeaders: headers.flatMap { try? JSONDecoder().decode([HTTPHeader].self, from: $0) } ?? [] ) ) diff --git a/Coder-Desktop/Coder-DesktopHelper/Manager.swift b/Coder-Desktop/Coder-DesktopHelper/Manager.swift index e2d47b8b..58827419 100644 --- a/Coder-Desktop/Coder-DesktopHelper/Manager.swift +++ b/Coder-Desktop/Coder-DesktopHelper/Manager.swift @@ -160,6 +160,7 @@ actor Manager { resp = try await speaker.unaryRPC( .with { msg in msg.start = .with { req in + req.tunnelUseSoftNetIsolation = cfg.useSoftNetIsolation req.tunnelFileDescriptor = cfg.tunFd req.apiToken = cfg.apiToken req.coderURL = cfg.serverUrl.absoluteString @@ -234,6 +235,7 @@ struct ManagerConfig { let apiToken: String let serverUrl: URL let tunFd: Int32 + let useSoftNetIsolation: Bool let literalHeaders: [HTTPHeader] } diff --git a/Coder-Desktop/VPN/HelperXPCSpeaker.swift b/Coder-Desktop/VPN/HelperXPCSpeaker.swift index 0549fc8e..7110ca3b 100644 --- a/Coder-Desktop/VPN/HelperXPCSpeaker.swift +++ b/Coder-Desktop/VPN/HelperXPCSpeaker.swift @@ -58,7 +58,13 @@ final class HelperXPCSpeaker: NEXPCInterface, @unchecked Sendable { // These methods are called to start and stop the daemon run by the Helper. extension HelperXPCSpeaker { - func startDaemon(accessURL: URL, token: String, tun: FileHandle, headers: Data?) async throws { + func startDaemon( + accessURL: URL, + token: String, + tun: FileHandle, + headers: Data?, + useSoftNetIsolation: Bool + ) async throws { let conn = connect() return try await withCheckedThrowingContinuation { continuation in guard let proxy = conn.remoteObjectProxyWithErrorHandler({ err in @@ -69,7 +75,13 @@ extension HelperXPCSpeaker { continuation.resume(throwing: XPCError.wrongProxyType) return } - proxy.startDaemon(accessURL: accessURL, token: token, tun: tun, headers: headers) { err in + proxy.startDaemon( + accessURL: accessURL, + token: token, + tun: tun, + headers: headers, + useSoftNetIsolation: useSoftNetIsolation + ) { err in if let error = err { self.logger.error("Failed to start daemon: \(error.localizedDescription, privacy: .public)") continuation.resume(throwing: error) diff --git a/Coder-Desktop/VPN/PacketTunnelProvider.swift b/Coder-Desktop/VPN/PacketTunnelProvider.swift index 6f54381a..606255b1 100644 --- a/Coder-Desktop/VPN/PacketTunnelProvider.swift +++ b/Coder-Desktop/VPN/PacketTunnelProvider.swift @@ -48,27 +48,31 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { ) async throws { globalHelperXPCSpeaker.ptp = self guard let proto = protocolConfiguration as? NETunnelProviderProtocol, - let baseAccessURL = proto.serverAddress + let accessURL = proto.serverAddress else { logger.error("startTunnel called with nil protocolConfiguration") throw makeNSError(suffix: "PTP", desc: "Missing Configuration") } // HACK: We can't write to the system keychain, and the NE can't read the user keychain. - guard let token = proto.providerConfiguration?["token"] as? String else { + guard let token = proto.providerConfiguration?[VPNConfigurationKeys.token] as? String else { logger.error("startTunnel called with nil token") throw makeNSError(suffix: "PTP", desc: "Missing Token") } - let headers = proto.providerConfiguration?["literalHeaders"] as? Data - logger.debug("retrieved token & access URL") + let headers = proto.providerConfiguration?[VPNConfigurationKeys.literalHeaders] as? Data + let useSoftNetIsolation = proto.providerConfiguration?[ + VPNConfigurationKeys.useSoftNetIsolation + ] as? Bool ?? false + logger.debug("retrieved vpn configuration settings") guard let tunFd = tunnelFileDescriptor else { logger.error("startTunnel called with nil tunnelFileDescriptor") throw makeNSError(suffix: "PTP", desc: "Missing Tunnel File Descriptor") } try await globalHelperXPCSpeaker.startDaemon( - accessURL: .init(string: baseAccessURL)!, + accessURL: .init(string: accessURL)!, token: token, tun: FileHandle(fileDescriptor: tunFd), - headers: headers + headers: headers, + useSoftNetIsolation: useSoftNetIsolation ) } diff --git a/Coder-Desktop/VPNLib/Configuration.swift b/Coder-Desktop/VPNLib/Configuration.swift new file mode 100644 index 00000000..3a93a88e --- /dev/null +++ b/Coder-Desktop/VPNLib/Configuration.swift @@ -0,0 +1,9 @@ +// Keys for the `providerConfiguration` dictionary in the VPN configuration plist. +public enum VPNConfigurationKeys { + // String + public static let token = "token" + // [(String, String)] + public static let literalHeaders = "literalHeaders" + // Bool + public static let useSoftNetIsolation = "useSoftNetIsolation" +} diff --git a/Coder-Desktop/VPNLib/Download.swift b/Coder-Desktop/VPNLib/Download.swift index 16a92032..63b0b964 100644 --- a/Coder-Desktop/VPNLib/Download.swift +++ b/Coder-Desktop/VPNLib/Download.swift @@ -150,15 +150,15 @@ extension DownloadManager: URLSessionDownloadDelegate { } public required convenience init?(coder: NSCoder) { - let written = coder.decodeInt64(forKey: "written") - let total = coder.containsValue(forKey: "total") ? coder.decodeInt64(forKey: "total") : nil + let written = coder.decodeInt64(forKey: Keys.written) + let total = coder.containsValue(forKey: Keys.total) ? coder.decodeInt64(forKey: Keys.total) : nil self.init(totalBytesWritten: written, totalBytesToWrite: total) } public func encode(with coder: NSCoder) { - coder.encode(totalBytesWritten, forKey: "written") + coder.encode(totalBytesWritten, forKey: Keys.written) if let total = totalBytesToWrite { - coder.encode(total, forKey: "total") + coder.encode(total, forKey: Keys.total) } } @@ -169,4 +169,9 @@ extension DownloadManager: URLSessionDownloadDelegate { let total = totalBytesToWrite.map { fmt.string(fromByteCount: $0) } ?? "Unknown" return "\(done) / \(total)" } + + enum Keys { + static let written = "written" + static let total = "total" + } } diff --git a/Coder-Desktop/VPNLib/XPC.swift b/Coder-Desktop/VPNLib/XPC.swift index 3ec3c266..4fae6f9e 100644 --- a/Coder-Desktop/VPNLib/XPC.swift +++ b/Coder-Desktop/VPNLib/XPC.swift @@ -25,8 +25,16 @@ public let helperNEMachServiceName = "4399GN35BJ.com.coder.Coder-Desktop.HelperN // This is the XPC interface the Helper exposes to the Network Extension. @preconcurrency @objc public protocol HelperNEXPCInterface { - // headers is a JSON `[HTTPHeader]` - func startDaemon(accessURL: URL, token: String, tun: FileHandle, headers: Data?, reply: @escaping (Error?) -> Void) + // swiftlint:disable:next function_parameter_count + func startDaemon( + accessURL: URL, + token: String, + tun: FileHandle, + // headers is a JSON encoded `[HTTPHeader]` + headers: Data?, + useSoftNetIsolation: Bool, + reply: @escaping (Error?) -> Void + ) func stopDaemon(reply: @escaping (Error?) -> Void) } diff --git a/Coder-Desktop/VPNLib/vpn.pb.swift b/Coder-Desktop/VPNLib/vpn.pb.swift index 3f630d0e..d569d530 100644 --- a/Coder-Desktop/VPNLib/vpn.pb.swift +++ b/Coder-Desktop/VPNLib/vpn.pb.swift @@ -757,6 +757,8 @@ public struct Vpn_StartRequest: Sendable { public var tunnelFileDescriptor: Int32 = 0 + public var tunnelUseSoftNetIsolation: Bool = false + public var coderURL: String = String() public var apiToken: String = String() @@ -2156,6 +2158,7 @@ extension Vpn_StartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme public static let protoMessageName: String = _protobuf_package + ".StartRequest" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .standard(proto: "tunnel_file_descriptor"), + 8: .standard(proto: "tunnel_use_soft_net_isolation"), 2: .standard(proto: "coder_url"), 3: .standard(proto: "api_token"), 4: .same(proto: "headers"), @@ -2177,6 +2180,7 @@ extension Vpn_StartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme case 5: try { try decoder.decodeSingularStringField(value: &self.deviceID) }() case 6: try { try decoder.decodeSingularStringField(value: &self.deviceOs) }() case 7: try { try decoder.decodeSingularStringField(value: &self.coderDesktopVersion) }() + case 8: try { try decoder.decodeSingularBoolField(value: &self.tunnelUseSoftNetIsolation) }() default: break } } @@ -2204,11 +2208,15 @@ extension Vpn_StartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme if !self.coderDesktopVersion.isEmpty { try visitor.visitSingularStringField(value: self.coderDesktopVersion, fieldNumber: 7) } + if self.tunnelUseSoftNetIsolation != false { + try visitor.visitSingularBoolField(value: self.tunnelUseSoftNetIsolation, fieldNumber: 8) + } try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Vpn_StartRequest, rhs: Vpn_StartRequest) -> Bool { if lhs.tunnelFileDescriptor != rhs.tunnelFileDescriptor {return false} + if lhs.tunnelUseSoftNetIsolation != rhs.tunnelUseSoftNetIsolation {return false} if lhs.coderURL != rhs.coderURL {return false} if lhs.apiToken != rhs.apiToken {return false} if lhs.headers != rhs.headers {return false} diff --git a/Coder-Desktop/VPNLib/vpn.proto b/Coder-Desktop/VPNLib/vpn.proto index 59ea1933..bd000279 100644 --- a/Coder-Desktop/VPNLib/vpn.proto +++ b/Coder-Desktop/VPNLib/vpn.proto @@ -213,6 +213,7 @@ message NetworkSettingsResponse { // StartResponse. message StartRequest { int32 tunnel_file_descriptor = 1; + bool tunnel_use_soft_net_isolation = 8; string coder_url = 2; string api_token = 3; // Additional HTTP headers added to all requests diff --git a/Coder-Desktop/project.yml b/Coder-Desktop/project.yml index 52056f5c..d32092a0 100644 --- a/Coder-Desktop/project.yml +++ b/Coder-Desktop/project.yml @@ -181,7 +181,7 @@ targets: # so that macOS stops complaining about the app being run from an # untrusted folder. DEPLOYMENT_LOCATION: YES - DSTROOT: $(LOCAL_APPS_DIR)/Coder + DSTROOT: $(LOCAL_APPS_DIR) INSTALL_PATH: / SKIP_INSTALL: NO LD_RUNPATH_SEARCH_PATHS: