Skip to content

Commit 3b95b81

Browse files
committed
feat: install and activate the tunnel provider as network extension
1 parent e9f5c6f commit 3b95b81

File tree

15 files changed

+386
-28
lines changed

15 files changed

+386
-28
lines changed

Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj

+6
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,7 @@
640640
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
641641
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
642642
CODE_SIGN_ENTITLEMENTS = "Coder Desktop/Coder_Desktop.entitlements";
643+
CODE_SIGN_IDENTITY = "Apple Development";
643644
CODE_SIGN_STYLE = Automatic;
644645
COMBINE_HIDPI_IMAGES = YES;
645646
CURRENT_PROJECT_VERSION = 1;
@@ -659,6 +660,7 @@
659660
MARKETING_VERSION = 1.0;
660661
PRODUCT_BUNDLE_IDENTIFIER = "com.coder.Coder-Desktop";
661662
PRODUCT_NAME = "$(TARGET_NAME)";
663+
PROVISIONING_PROFILE_SPECIFIER = "";
662664
SWIFT_EMIT_LOC_STRINGS = YES;
663665
SWIFT_VERSION = 6.0;
664666
};
@@ -670,6 +672,7 @@
670672
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
671673
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
672674
CODE_SIGN_ENTITLEMENTS = "Coder Desktop/Coder_Desktop.entitlements";
675+
CODE_SIGN_IDENTITY = "Apple Development";
673676
CODE_SIGN_STYLE = Automatic;
674677
COMBINE_HIDPI_IMAGES = YES;
675678
CURRENT_PROJECT_VERSION = 1;
@@ -689,6 +692,7 @@
689692
MARKETING_VERSION = 1.0;
690693
PRODUCT_BUNDLE_IDENTIFIER = "com.coder.Coder-Desktop";
691694
PRODUCT_NAME = "$(TARGET_NAME)";
695+
PROVISIONING_PROFILE_SPECIFIER = "";
692696
SWIFT_EMIT_LOC_STRINGS = YES;
693697
SWIFT_VERSION = 6.0;
694698
};
@@ -772,6 +776,7 @@
772776
isa = XCBuildConfiguration;
773777
buildSettings = {
774778
CODE_SIGN_ENTITLEMENTS = VPN/VPN.entitlements;
779+
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
775780
CODE_SIGN_STYLE = Automatic;
776781
CURRENT_PROJECT_VERSION = 1;
777782
DEAD_CODE_STRIPPING = YES;
@@ -799,6 +804,7 @@
799804
isa = XCBuildConfiguration;
800805
buildSettings = {
801806
CODE_SIGN_ENTITLEMENTS = VPN/VPN.entitlements;
807+
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
802808
CODE_SIGN_STYLE = Automatic;
803809
CURRENT_PROJECT_VERSION = 1;
804810
DEAD_CODE_STRIPPING = YES;

Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"originHash" : "aa8dd97dc6e28dedc4a5c45c435467a247486474bf3c1caf5e67085d52325132",
2+
"originHash" : "412b6d7f71f5522642086779f22408150d75d59e1ec156964763b059262b879d",
33
"pins" : [
44
{
55
"identity" : "alamofire",

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>

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
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import os
2+
import NetworkExtension
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 .failed(let 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+
28+
func configureNetworkExtension(proto: NETunnelProviderProtocol) async {
29+
// removing the old tunnels, rather than reconfiguring ensures that configuration changes
30+
// are picked up.
31+
do {
32+
try await removeNetworkExtension()
33+
} catch {
34+
logger.error("remove tunnel failed: \(error)")
35+
neState = .failed(error.localizedDescription)
36+
return
37+
}
38+
logger.debug("inserting new tunnel")
39+
40+
let tm = NETunnelProviderManager()
41+
tm.localizedDescription = "CoderVPN"
42+
tm.protocolConfiguration = proto
43+
44+
logger.debug("saving new tunnel")
45+
do {
46+
try await tm.saveToPreferences()
47+
} catch {
48+
logger.error("save tunnel failed: \(error)")
49+
neState = .failed(error.localizedDescription)
50+
}
51+
return
52+
}
53+
54+
internal func removeNetworkExtension() async throws(VPNServiceError) {
55+
do {
56+
let tunnels = try await NETunnelProviderManager.loadAllFromPreferences()
57+
for tunnel in tunnels {
58+
try await tunnel.removeFromPreferences()
59+
}
60+
} catch {
61+
throw VPNServiceError.internalError("couldn't remove tunnels: \(error)")
62+
}
63+
}
64+
65+
internal func enableNetworkExtension() async {
66+
do {
67+
let tm = try await getTunnelManager()
68+
if !tm.isEnabled {
69+
tm.isEnabled = true
70+
try await tm.saveToPreferences()
71+
logger.debug("saved tunnel with enabled=true")
72+
}
73+
try tm.connection.startVPNTunnel()
74+
} catch {
75+
logger.error("enable network extension: \(error)")
76+
neState = .failed(error.localizedDescription)
77+
return
78+
}
79+
logger.debug("enabled and started tunnel")
80+
neState = .enabled
81+
}
82+
83+
internal func disableNetworkExtension() async {
84+
do {
85+
let tm = try await getTunnelManager()
86+
tm.connection.stopVPNTunnel()
87+
tm.isEnabled = false
88+
89+
try await tm.saveToPreferences()
90+
} catch {
91+
logger.error("disable network extension: \(error)")
92+
neState = .failed(error.localizedDescription)
93+
return
94+
}
95+
logger.debug("saved tunnel with enabled=false")
96+
neState = .disbled
97+
}
98+
99+
private func getTunnelManager() async throws(VPNServiceError) -> NETunnelProviderManager {
100+
var tunnels: [NETunnelProviderManager] = []
101+
do {
102+
tunnels = try await NETunnelProviderManager.loadAllFromPreferences()
103+
} catch {
104+
throw VPNServiceError.internalError("couldn't load tunnels: \(error)")
105+
}
106+
if tunnels.isEmpty {
107+
throw VPNServiceError.internalError("no tunnels found")
108+
}
109+
return tunnels.first!
110+
}
111+
}
112+
113+
// we're going to mark NETunnelProviderManager as Sendable since there are official APIs that return
114+
// it async.
115+
extension NETunnelProviderManager: @unchecked @retroactive Sendable {}

Coder Desktop/Coder Desktop/Preview Content/PreviewSession.swift

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

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

Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift

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

34
@MainActor
45
final class PreviewVPN: Coder_Desktop.VPNService {
@@ -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
}

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(self.tunnelProviderProtocol()) }
4966
}
5067

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

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

0 commit comments

Comments
 (0)