Skip to content

Commit 9e0b05b

Browse files
spikecurtisethanndickson
authored andcommitted
feat: install and activate the tunnel provider as network extension
1 parent 46c2c09 commit 9e0b05b

14 files changed

+383
-28
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/Coder_DesktopApp.swift

+6-7
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ struct DesktopApp: App {
1111
EmptyView()
1212
}
1313
Window("Sign In", id: Windows.login.rawValue) {
14-
LoginForm<PreviewClient, PreviewSession>()
14+
LoginForm<CoderClient, SecureSession>()
1515
}.environmentObject(appDelegate.session)
1616
.windowResizability(.contentSize)
1717
}
@@ -20,18 +20,17 @@ struct DesktopApp: App {
2020
@MainActor
2121
class AppDelegate: NSObject, NSApplicationDelegate {
2222
private var menuBarExtra: FluidMenuBarExtra?
23-
let vpn: PreviewVPN
24-
let session: PreviewSession
23+
let vpn: CoderVPNService
24+
let session: SecureSession
2525

2626
override init() {
27-
// TODO: Replace with real implementations
28-
vpn = PreviewVPN()
29-
session = PreviewSession()
27+
vpn = CoderVPNService()
28+
session = SecureSession(onChange: vpn.configureTunnelProviderProtocol)
3029
}
3130

3231
func applicationDidFinishLaunching(_: Notification) {
3332
menuBarExtra = FluidMenuBarExtra(title: "Coder Desktop", image: "MenuBarIcon") {
34-
VPNMenu<PreviewVPN, PreviewSession>().frame(width: 256)
33+
VPNMenu<CoderVPNService, SecureSession>().frame(width: 256)
3534
.environmentObject(self.vpn)
3635
.environmentObject(self.session)
3736
}

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 VPNServiceError.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 VPNServiceError.internalError("couldn't load tunnels: \(error)")
103+
}
104+
if tunnels.isEmpty {
105+
throw VPNServiceError.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)