Skip to content

Commit 90d124c

Browse files
authoredJan 14, 2025··
feat: install and activate the tunnel provider as network extension (#20)
1 parent 46c2c09 commit 90d124c

13 files changed

+376
-21
lines changed
 

Diff for: ‎Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj

+6
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,7 @@
769769
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
770770
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
771771
CODE_SIGN_ENTITLEMENTS = "Coder Desktop/Coder_Desktop.entitlements";
772+
CODE_SIGN_IDENTITY = "Apple Development";
772773
CODE_SIGN_STYLE = Automatic;
773774
COMBINE_HIDPI_IMAGES = YES;
774775
CURRENT_PROJECT_VERSION = 1;
@@ -788,6 +789,7 @@
788789
MARKETING_VERSION = 1.0;
789790
PRODUCT_BUNDLE_IDENTIFIER = "com.coder.Coder-Desktop";
790791
PRODUCT_NAME = "$(TARGET_NAME)";
792+
PROVISIONING_PROFILE_SPECIFIER = "";
791793
SWIFT_EMIT_LOC_STRINGS = YES;
792794
SWIFT_VERSION = 6.0;
793795
};
@@ -799,6 +801,7 @@
799801
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
800802
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
801803
CODE_SIGN_ENTITLEMENTS = "Coder Desktop/Coder_Desktop.entitlements";
804+
CODE_SIGN_IDENTITY = "Apple Development";
802805
CODE_SIGN_STYLE = Automatic;
803806
COMBINE_HIDPI_IMAGES = YES;
804807
CURRENT_PROJECT_VERSION = 1;
@@ -818,6 +821,7 @@
818821
MARKETING_VERSION = 1.0;
819822
PRODUCT_BUNDLE_IDENTIFIER = "com.coder.Coder-Desktop";
820823
PRODUCT_NAME = "$(TARGET_NAME)";
824+
PROVISIONING_PROFILE_SPECIFIER = "";
821825
SWIFT_EMIT_LOC_STRINGS = YES;
822826
SWIFT_VERSION = 6.0;
823827
};
@@ -901,6 +905,7 @@
901905
isa = XCBuildConfiguration;
902906
buildSettings = {
903907
CODE_SIGN_ENTITLEMENTS = VPN/VPN.entitlements;
908+
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
904909
CODE_SIGN_STYLE = Automatic;
905910
CURRENT_PROJECT_VERSION = 1;
906911
DEAD_CODE_STRIPPING = YES;
@@ -932,6 +937,7 @@
932937
isa = XCBuildConfiguration;
933938
buildSettings = {
934939
CODE_SIGN_ENTITLEMENTS = VPN/VPN.entitlements;
940+
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
935941
CODE_SIGN_STYLE = Automatic;
936942
CURRENT_PROJECT_VERSION = 1;
937943
DEAD_CODE_STRIPPING = YES;

Diff for: ‎Coder Desktop/Coder Desktop/Coder_Desktop.entitlements

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
<array>
77
<string>packet-tunnel-provider</string>
88
</array>
9+
<key>com.apple.developer.system-extension.install</key>
10+
<true/>
911
<key>com.apple.security.app-sandbox</key>
1012
<true/>
1113
<key>com.apple.security.files.user-selected.read-only</key>

Diff for: ‎Coder Desktop/Coder Desktop/NetworkExtension.swift

+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import NetworkExtension
2+
import os
3+
4+
enum NetworkExtensionState: Equatable {
5+
case unconfigured
6+
case disbled
7+
case enabled
8+
case failed(String)
9+
10+
var description: String {
11+
switch self {
12+
case .unconfigured:
13+
return "Not logged in to Coder"
14+
case .enabled:
15+
return "NetworkExtension tunnel enabled"
16+
case .disbled:
17+
return "NetworkExtension tunnel disabled"
18+
case let .failed(error):
19+
return "NetworkExtension config failed: \(error)"
20+
}
21+
}
22+
}
23+
24+
/// An actor that handles configuring, enabling, and disabling the VPN tunnel via the
25+
/// NetworkExtension APIs.
26+
extension CoderVPNService {
27+
func configureNetworkExtension(proto: NETunnelProviderProtocol) async {
28+
// removing the old tunnels, rather than reconfiguring ensures that configuration changes
29+
// are picked up.
30+
do {
31+
try await removeNetworkExtension()
32+
} catch {
33+
logger.error("remove tunnel failed: \(error)")
34+
neState = .failed(error.localizedDescription)
35+
return
36+
}
37+
logger.debug("inserting new tunnel")
38+
39+
let tm = NETunnelProviderManager()
40+
tm.localizedDescription = "CoderVPN"
41+
tm.protocolConfiguration = proto
42+
43+
logger.debug("saving new tunnel")
44+
do {
45+
try await tm.saveToPreferences()
46+
} catch {
47+
logger.error("save tunnel failed: \(error)")
48+
neState = .failed(error.localizedDescription)
49+
}
50+
}
51+
52+
func removeNetworkExtension() async throws(VPNServiceError) {
53+
do {
54+
let tunnels = try await NETunnelProviderManager.loadAllFromPreferences()
55+
for tunnel in tunnels {
56+
try await tunnel.removeFromPreferences()
57+
}
58+
} catch {
59+
throw .internalError("couldn't remove tunnels: \(error)")
60+
}
61+
}
62+
63+
func enableNetworkExtension() async {
64+
do {
65+
let tm = try await getTunnelManager()
66+
if !tm.isEnabled {
67+
tm.isEnabled = true
68+
try await tm.saveToPreferences()
69+
logger.debug("saved tunnel with enabled=true")
70+
}
71+
try tm.connection.startVPNTunnel()
72+
} catch {
73+
logger.error("enable network extension: \(error)")
74+
neState = .failed(error.localizedDescription)
75+
return
76+
}
77+
logger.debug("enabled and started tunnel")
78+
neState = .enabled
79+
}
80+
81+
func disableNetworkExtension() async {
82+
do {
83+
let tm = try await getTunnelManager()
84+
tm.connection.stopVPNTunnel()
85+
tm.isEnabled = false
86+
87+
try await tm.saveToPreferences()
88+
} catch {
89+
logger.error("disable network extension: \(error)")
90+
neState = .failed(error.localizedDescription)
91+
return
92+
}
93+
logger.debug("saved tunnel with enabled=false")
94+
neState = .disbled
95+
}
96+
97+
private func getTunnelManager() async throws(VPNServiceError) -> NETunnelProviderManager {
98+
var tunnels: [NETunnelProviderManager] = []
99+
do {
100+
tunnels = try await NETunnelProviderManager.loadAllFromPreferences()
101+
} catch {
102+
throw .internalError("couldn't load tunnels: \(error)")
103+
}
104+
if tunnels.isEmpty {
105+
throw .internalError("no tunnels found")
106+
}
107+
return tunnels.first!
108+
}
109+
}
110+
111+
// we're going to mark NETunnelProviderManager as Sendable since there are official APIs that return
112+
// it async.
113+
extension NETunnelProviderManager: @unchecked @retroactive Sendable {}

Diff for: ‎Coder Desktop/Coder Desktop/Preview Content/PreviewSession.swift

+5
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import NetworkExtension
12
import SwiftUI
23

34
class PreviewSession: Session {
@@ -21,4 +22,8 @@ class PreviewSession: Session {
2122
hasSession = false
2223
sessionToken = nil
2324
}
25+
26+
func tunnelProviderProtocol() -> NETunnelProviderProtocol? {
27+
return nil
28+
}
2429
}

Diff for: ‎Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift

+8-3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import NetworkExtension
12
import SwiftUI
23

34
@MainActor
@@ -28,10 +29,10 @@ final class PreviewVPN: Coder_Desktop.VPNService {
2829
do {
2930
try await Task.sleep(for: .seconds(10))
3031
} catch {
31-
state = .failed(.exampleError)
32+
state = .failed(.longTestError)
3233
return
3334
}
34-
state = shouldFail ? .failed(.exampleError) : .connected
35+
state = shouldFail ? .failed(.longTestError) : .connected
3536
}
3637

3738
func stop() async {
@@ -40,9 +41,13 @@ final class PreviewVPN: Coder_Desktop.VPNService {
4041
do {
4142
try await Task.sleep(for: .seconds(10))
4243
} catch {
43-
state = .failed(.exampleError)
44+
state = .failed(.longTestError)
4445
return
4546
}
4647
state = .disabled
4748
}
49+
50+
func configureTunnelProviderProtocol(proto _: NETunnelProviderProtocol?) {
51+
state = .connecting
52+
}
4853
}

Diff for: ‎Coder Desktop/Coder Desktop/Session.swift

+20-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Foundation
22
import KeychainAccess
3+
import NetworkExtension
34

45
protocol Session: ObservableObject {
56
var hasSession: Bool { get }
@@ -8,9 +9,12 @@ protocol Session: ObservableObject {
89

910
func store(baseAccessURL: URL, sessionToken: String)
1011
func clear()
12+
func tunnelProviderProtocol() -> NETunnelProviderProtocol?
1113
}
1214

13-
class SecureSession: ObservableObject {
15+
class SecureSession: ObservableObject, Session {
16+
let appId = Bundle.main.bundleIdentifier!
17+
1418
// Stored in UserDefaults
1519
@Published private(set) var hasSession: Bool {
1620
didSet {
@@ -31,9 +35,21 @@ class SecureSession: ObservableObject {
3135
}
3236
}
3337

38+
func tunnelProviderProtocol() -> NETunnelProviderProtocol? {
39+
if !hasSession { return nil }
40+
let proto = NETunnelProviderProtocol()
41+
proto.providerBundleIdentifier = "\(appId).VPN"
42+
proto.passwordReference = keychain[attributes: Keys.sessionToken]?.persistentRef
43+
proto.serverAddress = baseAccessURL!.absoluteString
44+
return proto
45+
}
46+
3447
private let keychain: Keychain
3548

36-
public init() {
49+
let onChange: ((NETunnelProviderProtocol?) -> Void)?
50+
51+
public init(onChange: ((NETunnelProviderProtocol?) -> Void)? = nil) {
52+
self.onChange = onChange
3753
keychain = Keychain(service: Bundle.main.bundleIdentifier!)
3854
_hasSession = Published(initialValue: UserDefaults.standard.bool(forKey: Keys.hasSession))
3955
_baseAccessURL = Published(initialValue: UserDefaults.standard.url(forKey: Keys.baseAccessURL))
@@ -46,11 +62,13 @@ class SecureSession: ObservableObject {
4662
hasSession = true
4763
self.baseAccessURL = baseAccessURL
4864
self.sessionToken = sessionToken
65+
if let onChange { onChange(tunnelProviderProtocol()) }
4966
}
5067

5168
public func clear() {
5269
hasSession = false
5370
sessionToken = nil
71+
if let onChange { onChange(tunnelProviderProtocol()) }
5472
}
5573

5674
private func keychainGet(for key: String) -> String? {

0 commit comments

Comments
 (0)
Please sign in to comment.