Skip to content

fix: concurrently open tunnel & handshake #30

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

Merged
merged 2 commits into from
Jan 23, 2025
Merged
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
36 changes: 34 additions & 2 deletions Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
@@ -17,14 +17,17 @@
AA3B3DCE2D2D249F0099996A /* VPNLib.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = AA3B3DA12D2D23860099996A /* VPNLib.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
AA3B3E8E2D2E0FF40099996A /* Mocker in Frameworks */ = {isa = PBXBuildFile; productRef = AA3B3E8D2D2E0FF40099996A /* Mocker */; };
AA3B40992D2FC8560099996A /* CoderSDK.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA3B40912D2FC8560099996A /* CoderSDK.framework */; };
AA3B40A42D2FC8560099996A /* CoderSDK.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA3B40912D2FC8560099996A /* CoderSDK.framework */; };
AA3B40B62D2FD9DD0099996A /* Mocker in Frameworks */ = {isa = PBXBuildFile; productRef = AA3B40B52D2FD9DD0099996A /* Mocker */; };
AA3B40B72D2FDA5C0099996A /* CoderSDK.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA3B40912D2FC8560099996A /* CoderSDK.framework */; };
AA3B40BD2D2FDFBA0099996A /* Mocker in Frameworks */ = {isa = PBXBuildFile; productRef = AA3B40BC2D2FDFBA0099996A /* Mocker */; };
AA3B40C02D2FE7760099996A /* CoderSDK.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA3B40912D2FC8560099996A /* CoderSDK.framework */; };
AA8BC3392D0060A900E1ABAA /* ViewInspector in Frameworks */ = {isa = PBXBuildFile; productRef = AA8BC3382D0060A900E1ABAA /* ViewInspector */; };
AA8BC33F2D0061F200E1ABAA /* FluidMenuBarExtra in Frameworks */ = {isa = PBXBuildFile; productRef = AA8BC33E2D0061F200E1ABAA /* FluidMenuBarExtra */; };
AA8BC4CF2D00A4B700E1ABAA /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = AA8BC4CE2D00A4B700E1ABAA /* KeychainAccess */; };
AAC382352D427B7600F6DFB4 /* CoderSDK.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA3B40912D2FC8560099996A /* CoderSDK.framework */; };
AAC382362D427B7600F6DFB4 /* CoderSDK.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = AA3B40912D2FC8560099996A /* CoderSDK.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
AAC382392D427B8300F6DFB4 /* CoderSDK.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA3B40912D2FC8560099996A /* CoderSDK.framework */; };
AAC3823A2D427B8300F6DFB4 /* CoderSDK.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = AA3B40912D2FC8560099996A /* CoderSDK.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
@@ -105,6 +108,13 @@
remoteGlobalIDString = AA3B40902D2FC8560099996A;
remoteInfo = CoderSDK;
};
AAC382372D427B7600F6DFB4 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 961678F42CFF100D00B2B6DF /* Project object */;
proxyType = 1;
remoteGlobalIDString = AA3B40902D2FC8560099996A;
remoteInfo = CoderSDK;
};
/* End PBXContainerItemProxy section */

/* Begin PBXCopyFilesBuildPhase section */
@@ -126,6 +136,18 @@
dstSubfolderSpec = 10;
files = (
AA3B3DCE2D2D249F0099996A /* VPNLib.framework in Embed Frameworks */,
AAC382362D427B7600F6DFB4 /* CoderSDK.framework in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
AAC3823B2D427B8300F6DFB4 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
AAC3823A2D427B8300F6DFB4 /* CoderSDK.framework in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
@@ -228,8 +250,8 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
AA3B40A42D2FC8560099996A /* CoderSDK.framework in Frameworks */,
AA8BC4CF2D00A4B700E1ABAA /* KeychainAccess in Frameworks */,
AAC382392D427B8300F6DFB4 /* CoderSDK.framework in Frameworks */,
AA2C690F2D34F6920059AFAF /* LaunchAtLogin in Frameworks */,
AA8BC33F2D0061F200E1ABAA /* FluidMenuBarExtra in Frameworks */,
);
@@ -257,6 +279,7 @@
buildActionMask = 2147483647;
files = (
961679332CFF117300B2B6DF /* NetworkExtension.framework in Frameworks */,
AAC382352D427B7600F6DFB4 /* CoderSDK.framework in Frameworks */,
AA3B3DCD2D2D249F0099996A /* VPNLib.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -366,6 +389,7 @@
961678F92CFF100D00B2B6DF /* Frameworks */,
961678FA2CFF100D00B2B6DF /* Resources */,
961679422CFF117300B2B6DF /* Embed System Extensions */,
AAC3823B2D427B8300F6DFB4 /* Embed Frameworks */,
);
buildRules = (
);
@@ -452,6 +476,7 @@
dependencies = (
AA2C69922D354A8B0059AFAF /* PBXTargetDependency */,
AA3B3DD02D2D249F0099996A /* PBXTargetDependency */,
AAC382382D427B7600F6DFB4 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
AA3C69AD2D2D143400A45481 /* VPN */,
@@ -847,6 +872,11 @@
target = AA3B40902D2FC8560099996A /* CoderSDK */;
targetProxy = AA3B40C22D2FE7760099996A /* PBXContainerItemProxy */;
};
AAC382382D427B7600F6DFB4 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = AA3B40902D2FC8560099996A /* CoderSDK */;
targetProxy = AAC382372D427B7600F6DFB4 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */

/* Begin XCBuildConfiguration section */
@@ -1216,6 +1246,7 @@
buildSettings = {
BUILD_LIBRARY_FOR_DISTRIBUTION = NO;
CODE_SIGN_IDENTITY = "";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
@@ -1324,6 +1355,7 @@
buildSettings = {
BUILD_LIBRARY_FOR_DISTRIBUTION = NO;
CODE_SIGN_IDENTITY = "";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
16 changes: 9 additions & 7 deletions Coder Desktop/Coder Desktop/Coder_DesktopApp.swift
Original file line number Diff line number Diff line change
@@ -11,10 +11,12 @@ struct DesktopApp: App {
EmptyView()
}
Window("Sign In", id: Windows.login.rawValue) {
LoginForm<PreviewSession>().environmentObject(appDelegate.session)
LoginForm<SecureSession>()
.environmentObject(appDelegate.session)
.environmentObject(appDelegate.settings)
}
.windowResizability(.contentSize)
SwiftUI.Settings { SettingsView<PreviewVPN>()
SwiftUI.Settings { SettingsView<CoderVPNService>()
.environmentObject(appDelegate.vpn)
.environmentObject(appDelegate.settings)
}
@@ -25,20 +27,20 @@ struct DesktopApp: App {
@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
private var menuBarExtra: FluidMenuBarExtra?
let vpn: PreviewVPN
let session: PreviewSession
let vpn: CoderVPNService
let session: SecureSession
let settings: Settings

override init() {
// TODO: Replace with real implementation
vpn = PreviewVPN()
vpn = CoderVPNService()
settings = Settings()
session = PreviewSession()
session = SecureSession(onChange: vpn.configureTunnelProviderProtocol)
}

func applicationDidFinishLaunching(_: Notification) {
menuBarExtra = FluidMenuBarExtra(title: "Coder Desktop", image: "MenuBarIcon") {
VPNMenu<PreviewVPN, PreviewSession>().frame(width: 256)
VPNMenu<CoderVPNService, SecureSession>().frame(width: 256)
.environmentObject(self.vpn)
.environmentObject(self.session)
.environmentObject(self.settings)
20 changes: 16 additions & 4 deletions Coder Desktop/Coder Desktop/NetworkExtension.swift
Original file line number Diff line number Diff line change
@@ -3,17 +3,17 @@ import os

enum NetworkExtensionState: Equatable {
case unconfigured
case disbled
case disabled
case enabled
case failed(String)

var description: String {
switch self {
case .unconfigured:
return "Not logged in to Coder"
return "NetworkExtension not configured, try logging in again"
case .enabled:
return "NetworkExtension tunnel enabled"
case .disbled:
case .disabled:
return "NetworkExtension tunnel disabled"
case let .failed(error):
return "NetworkExtension config failed: \(error)"
@@ -24,6 +24,16 @@ enum NetworkExtensionState: Equatable {
/// An actor that handles configuring, enabling, and disabling the VPN tunnel via the
/// NetworkExtension APIs.
extension CoderVPNService {
// Updates the UI if a previous configuration exists
func loadNetworkExtension() async {
do {
try await getTunnelManager()
neState = .disabled
} catch {
neState = .unconfigured
}
}

func configureNetworkExtension(proto: NETunnelProviderProtocol) async {
// removing the old tunnels, rather than reconfiguring ensures that configuration changes
// are picked up.
@@ -47,6 +57,7 @@ extension CoderVPNService {
logger.error("save tunnel failed: \(error)")
neState = .failed(error.localizedDescription)
}
neState = .disabled
}

func removeNetworkExtension() async throws(VPNServiceError) {
@@ -91,9 +102,10 @@ extension CoderVPNService {
return
}
logger.debug("saved tunnel with enabled=false")
neState = .disbled
neState = .disabled
}

@discardableResult
private func getTunnelManager() async throws(VPNServiceError) -> NETunnelProviderManager {
var tunnels: [NETunnelProviderManager] = []
do {
5 changes: 4 additions & 1 deletion Coder Desktop/Coder Desktop/VPNService.swift
Original file line number Diff line number Diff line change
@@ -50,7 +50,7 @@ final class CoderVPNService: NSObject, VPNService {
guard sysExtnState == .installed else {
return .failed(.systemExtensionError(sysExtnState))
}
guard neState == .enabled || neState == .disbled else {
guard neState == .enabled || neState == .disabled else {
return .failed(.networkExtensionError(neState))
}
return tunnelState
@@ -66,6 +66,9 @@ final class CoderVPNService: NSObject, VPNService {
override init() {
super.init()
installSystemExtension()
Task {
await loadNetworkExtension()
}
Comment on lines +69 to +71
Copy link
Member Author

Choose a reason for hiding this comment

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

This avoids prompting the user to configure the system VPN each time they launch the app.

}

var startTask: Task<Void, Never>?
38 changes: 35 additions & 3 deletions Coder Desktop/VPN/Manager.swift
Original file line number Diff line number Diff line change
@@ -16,13 +16,14 @@ actor Manager {
.first!.appending(path: "coder-vpn.dylib")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "manager")

// swiftlint:disable:next function_body_length
init(with: PacketTunnelProvider, cfg: ManagerConfig) async throws(ManagerError) {
ptp = with
self.cfg = cfg
#if arch(arm64)
let dylibPath = cfg.serverUrl.appending(path: "bin/coder-vpn-arm64.dylib")
let dylibPath = cfg.serverUrl.appending(path: "bin/coder-vpn-darwin-arm64.dylib")
#elseif arch(x86_64)
let dylibPath = cfg.serverUrl.appending(path: "bin/coder-vpn-amd64.dylib")
let dylibPath = cfg.serverUrl.appending(path: "bin/coder-vpn-darwin-amd64.dylib")
#else
fatalError("unknown architecture")
#endif
@@ -60,6 +61,14 @@ actor Manager {
} catch {
throw .handshake(error)
}
do {
try await tunnelHandle.openTunnelTask?.value
} catch let error as TunnelHandleError {
logger.error("failed to wait for dylib to open tunnel: \(error, privacy: .public) ")
throw .tunnelSetup(error)
} catch {
fatalError("openTunnelTask must only throw TunnelHandleError")
}
readLoop = Task { try await run() }
}

@@ -180,7 +189,7 @@ actor Manager {
}
}

public struct ManagerConfig {
struct ManagerConfig {
let apiToken: String
let serverUrl: URL
}
@@ -195,6 +204,29 @@ enum ManagerError: Error {
case serverInfo(String)
case errorResponse(msg: String)
case noTunnelFileDescriptor

var description: String {
switch self {
case let .download(err):
return "Download error: \(err)"
case let .tunnelSetup(err):
return "Tunnel setup error: \(err)"
case let .handshake(err):
return "Handshake error: \(err)"
case let .validation(err):
return "Validation error: \(err)"
case .incorrectResponse:
return "Received unexpected response over tunnel"
case let .failedRPC(err):
return "Failed rpc: \(err)"
case let .serverInfo(msg):
return msg
case let .errorResponse(msg):
return msg
case .noTunnelFileDescriptor:
return "Could not find a tunnel file descriptor"
}
}
}

func writeVpnLog(_ log: Vpn_Log) {
21 changes: 14 additions & 7 deletions Coder Desktop/VPN/PacketTunnelProvider.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import NetworkExtension
import os
import VPNLib

/* From <sys/kern_control.h> */
let CTLIOCGINFO: UInt = 0xC064_4E03
@@ -8,7 +9,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "provider")
private var manager: Manager?

public var tunnelFileDescriptor: Int32? {
var tunnelFileDescriptor: Int32? {
var ctlInfo = ctl_info()
withUnsafeMutablePointer(to: &ctlInfo.ctl_name) {
$0.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size(ofValue: $0.pointee)) {
@@ -47,19 +48,25 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
completionHandler(nil)
return
}
let completionHandler = CallbackWrapper(completionHandler)
Task {
// TODO: Retrieve access URL & Token via Keychain
manager = try await Manager(
with: self,
cfg: .init(apiToken: "fake-token", serverUrl: .init(string: "https://dev.coder.com")!)
)
do throws(ManagerError) {
manager = try await Manager(
with: self,
cfg: .init(apiToken: "fake-token", serverUrl: .init(string: "https://dev.coder.com")!)
)
completionHandler(nil)
} catch {
completionHandler(error)
logger.error("error starting manager: \(error.description, privacy: .public)")
}
}
completionHandler(nil)
}

override func stopTunnel(with _: NEProviderStopReason, completionHandler: @escaping () -> Void) {
logger.debug("stopTunnel called")
guard manager == nil else {
guard manager != nil else {
logger.error("stopTunnel called with nil Manager")
completionHandler()
return
33 changes: 28 additions & 5 deletions Coder Desktop/VPN/TunnelHandle.swift
Original file line number Diff line number Diff line change
@@ -13,6 +13,9 @@ actor TunnelHandle {
var writeHandle: FileHandle { tunnelReadPipe.fileHandleForWriting }
var readHandle: FileHandle { tunnelWritePipe.fileHandleForReading }

// MUST only ever throw TunnelHandleError
var openTunnelTask: Task<Void, any Error>?

init(dylibPath: URL) throws(TunnelHandleError) {
guard let dylibHandle = dlopen(dylibPath.path, RTLD_NOW | RTLD_LOCAL) else {
throw .dylib(dlerror().flatMap { String(cString: $0) } ?? "UNKNOWN")
@@ -22,13 +25,22 @@ actor TunnelHandle {
guard let startSym = dlsym(dylibHandle, startSymbol) else {
throw .symbol(startSymbol, dlerror().flatMap { String(cString: $0) } ?? "UNKNOWN")
}
let openTunnelFn = unsafeBitCast(startSym, to: OpenTunnel.self)
let openTunnelFn = SendableOpenTunnel(unsafeBitCast(startSym, to: OpenTunnel.self))
tunnelReadPipe = Pipe()
tunnelWritePipe = Pipe()
let res = openTunnelFn(tunnelReadPipe.fileHandleForReading.fileDescriptor,
tunnelWritePipe.fileHandleForWriting.fileDescriptor)
guard res == 0 else {
throw .openTunnel(OpenTunnelError(rawValue: res) ?? .unknown)
let rfd = tunnelReadPipe.fileHandleForReading.fileDescriptor
let wfd = tunnelWritePipe.fileHandleForWriting.fileDescriptor
openTunnelTask = Task { [openTunnelFn] in
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, any Error>) in
DispatchQueue.global().async {
let res = openTunnelFn(rfd, wfd)
guard res == 0 else {
cont.resume(throwing: TunnelHandleError.openTunnel(OpenTunnelError(rawValue: res) ?? .unknown))
return
}
cont.resume()
}
}
}
}

@@ -89,3 +101,14 @@ enum OpenTunnelError: Int32 {
}
}
}

struct SendableOpenTunnel: @unchecked Sendable {
let fn: OpenTunnel
init(_ function: OpenTunnel) {
fn = function
}

func callAsFunction(_ lhs: Int32, _ rhs: Int32) -> Int32 {
fn(lhs, rhs)
}
}
2 changes: 2 additions & 0 deletions Coder Desktop/VPN/VPN.entitlements
Original file line number Diff line number Diff line change
@@ -12,5 +12,7 @@
<array>
<string>$(TeamIdentifierPrefix)com.coder.Coder-Desktop</string>
</array>
<key>com.apple.security.network.client</key>
Copy link
Member Author

Choose a reason for hiding this comment

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

Entitlement required for the network extension to be able to reach the internet, i.e. download the dylib,.

<true/>
</dict>
</plist>
12 changes: 12 additions & 0 deletions Coder Desktop/VPNLib/Util.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
public final class CallbackWrapper<T, U>: @unchecked Sendable {
private let block: (T?) -> U

public init(_ block: @escaping (T?) -> U) {
self.block = block
}

public func callAsFunction(_ error: T?) -> U {
// Just forward to the original block
block(error)
}
}