Skip to content

Commit 4bb3a24

Browse files
committed
fix: add toggle for Coder deployments behind a VPN
1 parent 5bf788f commit 4bb3a24

File tree

12 files changed

+109
-20
lines changed

12 files changed

+109
-20
lines changed

Coder-Desktop/Coder-Desktop/State.swift

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import KeychainAccess
44
import NetworkExtension
55
import os
66
import SwiftUI
7+
import VPNLib
78

89
@MainActor
910
class AppState: ObservableObject {
@@ -70,6 +71,14 @@ class AppState: ObservableObject {
7071
}
7172
}
7273

74+
@Published var useSoftNetIsolation: Bool = UserDefaults.standard.bool(forKey: Keys.useSoftNetIsolation) {
75+
didSet {
76+
reconfigure()
77+
guard persistent else { return }
78+
UserDefaults.standard.set(useSoftNetIsolation, forKey: Keys.useSoftNetIsolation)
79+
}
80+
}
81+
7382
@Published var skipHiddenIconAlert: Bool = UserDefaults.standard.bool(forKey: Keys.skipHiddenIconAlert) {
7483
didSet {
7584
guard persistent else { return }
@@ -81,11 +90,18 @@ class AppState: ObservableObject {
8190
if !hasSession { return nil }
8291
let proto = NETunnelProviderProtocol()
8392
proto.providerBundleIdentifier = "\(appId).VPN"
84-
// HACK: We can't write to the system keychain, and the user keychain
85-
// isn't accessible, so we'll use providerConfiguration, which is over XPC.
86-
proto.providerConfiguration = ["token": sessionToken!]
87-
if useLiteralHeaders, let headers = try? JSONEncoder().encode(literalHeaders) {
88-
proto.providerConfiguration?["literalHeaders"] = headers
93+
94+
proto.providerConfiguration = [
95+
// HACK: We can't write to the system keychain, and the user keychain
96+
// isn't accessible, so we'll use providerConfiguration, which
97+
// writes to disk.
98+
VPNConfigurationKeys.token: sessionToken!,
99+
VPNConfigurationKeys.useSoftNetIsolation: useSoftNetIsolation,
100+
]
101+
if useLiteralHeaders {
102+
proto.providerConfiguration?[
103+
VPNConfigurationKeys.literalHeaders
104+
] = literalHeaders.map { ($0.name, $0.value) }
89105
}
90106
proto.serverAddress = baseAccessURL!.absoluteString
91107
return proto
@@ -188,6 +204,7 @@ class AppState: ObservableObject {
188204
}
189205

190206
public func clearSession() {
207+
logger.info("clearing session")
191208
hasSession = false
192209
sessionToken = nil
193210
refreshTask?.cancel()
@@ -216,6 +233,7 @@ class AppState: ObservableObject {
216233

217234
static let useLiteralHeaders = "UseLiteralHeaders"
218235
static let literalHeaders = "LiteralHeaders"
236+
static let useSoftNetIsolation = "UseSoftNetIsolation"
219237
static let stopVPNOnQuit = "StopVPNOnQuit"
220238
static let startVPNOnLaunch = "StartVPNOnLaunch"
221239

Coder-Desktop/Coder-Desktop/Views/Settings/NetworkTab.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,30 @@ struct NetworkTab<VPN: VPNService>: View {
44
var body: some View {
55
Form {
66
LiteralHeadersSection<VPN>()
7+
SoftNetIsolationSection<VPN>()
78
}
89
.formStyle(.grouped)
910
}
1011
}
1112

13+
struct SoftNetIsolationSection<VPN: VPNService>: View {
14+
@EnvironmentObject var state: AppState
15+
@EnvironmentObject var vpn: VPN
16+
var body: some View {
17+
Section {
18+
Toggle(isOn: $state.useSoftNetIsolation) {
19+
Text("Enable support for corporate VPNs")
20+
if !vpn.state.canBeStarted { Text("Cannot be modified while Coder Connect is enabled.") }
21+
}
22+
Text("This setting loosens the VPN loop protection in Coder Connect, allowing traffic to flow to a " +
23+
"Coder deployment behind a corporate VPN. We only recommend enabling this option if Coder Connect " +
24+
"doesn't work with your Coder deployment behind a corporate VPN.")
25+
.font(.subheadline)
26+
.foregroundStyle(.secondary)
27+
}.disabled(!vpn.state.canBeStarted)
28+
}
29+
}
30+
1231
#if DEBUG
1332
#Preview {
1433
NetworkTab<PreviewVPN>()

Coder-Desktop/Coder-DesktopHelper/HelperXPCListeners.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,13 @@ class HelperNEXPCListener: NSObject, NSXPCListenerDelegate, HelperNEXPCInterface
4040

4141
let startSymbol = "OpenTunnel"
4242

43+
// swiftlint:disable:next function_parameter_count
4344
func startDaemon(
4445
accessURL: URL,
4546
token: String,
4647
tun: FileHandle,
4748
headers: Data?,
49+
useSoftNetIsolation: Bool,
4850
reply: @escaping (Error?) -> Void
4951
) {
5052
logger.info("startDaemon called")
@@ -57,6 +59,7 @@ class HelperNEXPCListener: NSObject, NSXPCListenerDelegate, HelperNEXPCInterface
5759
apiToken: token,
5860
serverUrl: accessURL,
5961
tunFd: tun.fileDescriptor,
62+
useSoftNetIsolation: useSoftNetIsolation,
6063
literalHeaders: headers.flatMap { try? JSONDecoder().decode([HTTPHeader].self, from: $0) } ?? []
6164
)
6265
)

Coder-Desktop/Coder-DesktopHelper/Manager.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ actor Manager {
160160
resp = try await speaker.unaryRPC(
161161
.with { msg in
162162
msg.start = .with { req in
163+
req.tunnelUseSoftNetIsolation = cfg.useSoftNetIsolation
163164
req.tunnelFileDescriptor = cfg.tunFd
164165
req.apiToken = cfg.apiToken
165166
req.coderURL = cfg.serverUrl.absoluteString
@@ -234,6 +235,7 @@ struct ManagerConfig {
234235
let apiToken: String
235236
let serverUrl: URL
236237
let tunFd: Int32
238+
let useSoftNetIsolation: Bool
237239
let literalHeaders: [HTTPHeader]
238240
}
239241

Coder-Desktop/VPN/HelperXPCSpeaker.swift

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,13 @@ final class HelperXPCSpeaker: NEXPCInterface, @unchecked Sendable {
5858

5959
// These methods are called to start and stop the daemon run by the Helper.
6060
extension HelperXPCSpeaker {
61-
func startDaemon(accessURL: URL, token: String, tun: FileHandle, headers: Data?) async throws {
61+
func startDaemon(
62+
accessURL: URL,
63+
token: String,
64+
tun: FileHandle,
65+
headers: Data?,
66+
useSoftNetIsolation: Bool
67+
) async throws {
6268
let conn = connect()
6369
return try await withCheckedThrowingContinuation { continuation in
6470
guard let proxy = conn.remoteObjectProxyWithErrorHandler({ err in
@@ -69,7 +75,13 @@ extension HelperXPCSpeaker {
6975
continuation.resume(throwing: XPCError.wrongProxyType)
7076
return
7177
}
72-
proxy.startDaemon(accessURL: accessURL, token: token, tun: tun, headers: headers) { err in
78+
proxy.startDaemon(
79+
accessURL: accessURL,
80+
token: token,
81+
tun: tun,
82+
headers: headers,
83+
useSoftNetIsolation: useSoftNetIsolation
84+
) { err in
7385
if let error = err {
7486
self.logger.error("Failed to start daemon: \(error.localizedDescription, privacy: .public)")
7587
continuation.resume(throwing: error)

Coder-Desktop/VPN/PacketTunnelProvider.swift

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,27 +48,31 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
4848
) async throws {
4949
globalHelperXPCSpeaker.ptp = self
5050
guard let proto = protocolConfiguration as? NETunnelProviderProtocol,
51-
let baseAccessURL = proto.serverAddress
51+
let accessURL = proto.serverAddress
5252
else {
5353
logger.error("startTunnel called with nil protocolConfiguration")
5454
throw makeNSError(suffix: "PTP", desc: "Missing Configuration")
5555
}
5656
// HACK: We can't write to the system keychain, and the NE can't read the user keychain.
57-
guard let token = proto.providerConfiguration?["token"] as? String else {
57+
guard let token = proto.providerConfiguration?[VPNConfigurationKeys.token] as? String else {
5858
logger.error("startTunnel called with nil token")
5959
throw makeNSError(suffix: "PTP", desc: "Missing Token")
6060
}
61-
let headers = proto.providerConfiguration?["literalHeaders"] as? Data
62-
logger.debug("retrieved token & access URL")
61+
let headers = proto.providerConfiguration?[VPNConfigurationKeys.literalHeaders] as? Data
62+
let useSoftNetIsolation = proto.providerConfiguration?[
63+
VPNConfigurationKeys.useSoftNetIsolation
64+
] as? Bool ?? false
65+
logger.debug("retrieved vpn configuration settings")
6366
guard let tunFd = tunnelFileDescriptor else {
6467
logger.error("startTunnel called with nil tunnelFileDescriptor")
6568
throw makeNSError(suffix: "PTP", desc: "Missing Tunnel File Descriptor")
6669
}
6770
try await globalHelperXPCSpeaker.startDaemon(
68-
accessURL: .init(string: baseAccessURL)!,
71+
accessURL: .init(string: accessURL)!,
6972
token: token,
7073
tun: FileHandle(fileDescriptor: tunFd),
71-
headers: headers
74+
headers: headers,
75+
useSoftNetIsolation: useSoftNetIsolation
7276
)
7377
}
7478

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Keys for the `providerConfiguration` dictionary in the VPN configuration plist.
2+
public enum VPNConfigurationKeys {
3+
// String
4+
public static let token = "token"
5+
// [(String, String)]
6+
public static let literalHeaders = "literalHeaders"
7+
// Bool
8+
public static let useSoftNetIsolation = "useSoftNetIsolation"
9+
}

Coder-Desktop/VPNLib/Download.swift

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,15 +150,15 @@ extension DownloadManager: URLSessionDownloadDelegate {
150150
}
151151

152152
public required convenience init?(coder: NSCoder) {
153-
let written = coder.decodeInt64(forKey: "written")
154-
let total = coder.containsValue(forKey: "total") ? coder.decodeInt64(forKey: "total") : nil
153+
let written = coder.decodeInt64(forKey: Keys.written)
154+
let total = coder.containsValue(forKey: Keys.total) ? coder.decodeInt64(forKey: Keys.total) : nil
155155
self.init(totalBytesWritten: written, totalBytesToWrite: total)
156156
}
157157

158158
public func encode(with coder: NSCoder) {
159-
coder.encode(totalBytesWritten, forKey: "written")
159+
coder.encode(totalBytesWritten, forKey: Keys.written)
160160
if let total = totalBytesToWrite {
161-
coder.encode(total, forKey: "total")
161+
coder.encode(total, forKey: Keys.total)
162162
}
163163
}
164164

@@ -169,4 +169,9 @@ extension DownloadManager: URLSessionDownloadDelegate {
169169
let total = totalBytesToWrite.map { fmt.string(fromByteCount: $0) } ?? "Unknown"
170170
return "\(done) / \(total)"
171171
}
172+
173+
enum Keys {
174+
static let written = "written"
175+
static let total = "total"
176+
}
172177
}

Coder-Desktop/VPNLib/XPC.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,16 @@ public let helperNEMachServiceName = "4399GN35BJ.com.coder.Coder-Desktop.HelperN
2525
// This is the XPC interface the Helper exposes to the Network Extension.
2626
@preconcurrency
2727
@objc public protocol HelperNEXPCInterface {
28-
// headers is a JSON `[HTTPHeader]`
29-
func startDaemon(accessURL: URL, token: String, tun: FileHandle, headers: Data?, reply: @escaping (Error?) -> Void)
28+
// swiftlint:disable:next function_parameter_count
29+
func startDaemon(
30+
accessURL: URL,
31+
token: String,
32+
tun: FileHandle,
33+
// headers is a JSON encoded `[HTTPHeader]`
34+
headers: Data?,
35+
useSoftNetIsolation: Bool,
36+
reply: @escaping (Error?) -> Void
37+
)
3038
func stopDaemon(reply: @escaping (Error?) -> Void)
3139
}
3240

Coder-Desktop/VPNLib/vpn.pb.swift

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)