Skip to content

fix: add toggle for coder deployments behind a vpn #209

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

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
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
28 changes: 23 additions & 5 deletions Coder-Desktop/Coder-Desktop/State.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import KeychainAccess
import NetworkExtension
import os
import SwiftUI
import VPNLib

@MainActor
class AppState: ObservableObject {
Expand Down Expand Up @@ -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 }
Expand All @@ -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
Expand Down Expand Up @@ -188,6 +204,7 @@ class AppState: ObservableObject {
}

public func clearSession() {
logger.info("clearing session")
hasSession = false
sessionToken = nil
refreshTask?.cancel()
Expand Down Expand Up @@ -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"

Expand Down
19 changes: 19 additions & 0 deletions Coder-Desktop/Coder-Desktop/Views/Settings/NetworkTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,30 @@ struct NetworkTab<VPN: VPNService>: View {
var body: some View {
Form {
LiteralHeadersSection<VPN>()
SoftNetIsolationSection<VPN>()
}
.formStyle(.grouped)
}
}

struct SoftNetIsolationSection<VPN: VPNService>: 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<PreviewVPN>()
Expand Down
3 changes: 3 additions & 0 deletions Coder-Desktop/Coder-DesktopHelper/HelperXPCListeners.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,13 @@ class HelperNEXPCListener: NSObject, NSXPCListenerDelegate, HelperNEXPCInterface

let startSymbol = "OpenTunnel"

// swiftlint:disable:next function_parameter_count
Copy link
Member Author

@ethanndickson ethanndickson Jul 28, 2025

Choose a reason for hiding this comment

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

Moving these parameters behind a struct introduces a surprising amount of complexity, as it needs to manually be NSSecureCodable - I don't see any immediate value in doing so.

func startDaemon(
accessURL: URL,
token: String,
tun: FileHandle,
headers: Data?,
useSoftNetIsolation: Bool,
reply: @escaping (Error?) -> Void
) {
logger.info("startDaemon called")
Expand All @@ -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) } ?? []
)
)
Expand Down
2 changes: 2 additions & 0 deletions Coder-Desktop/Coder-DesktopHelper/Manager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -234,6 +235,7 @@ struct ManagerConfig {
let apiToken: String
let serverUrl: URL
let tunFd: Int32
let useSoftNetIsolation: Bool
let literalHeaders: [HTTPHeader]
}

Expand Down
16 changes: 14 additions & 2 deletions Coder-Desktop/VPN/HelperXPCSpeaker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
16 changes: 10 additions & 6 deletions Coder-Desktop/VPN/PacketTunnelProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

Expand Down
9 changes: 9 additions & 0 deletions Coder-Desktop/VPNLib/Configuration.swift
Original file line number Diff line number Diff line change
@@ -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"
}
13 changes: 9 additions & 4 deletions Coder-Desktop/VPNLib/Download.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand All @@ -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"
}
}
12 changes: 10 additions & 2 deletions Coder-Desktop/VPNLib/XPC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
8 changes: 8 additions & 0 deletions Coder-Desktop/VPNLib/vpn.pb.swift

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Coder-Desktop/VPNLib/vpn.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Coder-Desktop/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading