Skip to content

chore: support operating the VPN without the app #36

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 7 commits into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
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
6 changes: 5 additions & 1 deletion Coder Desktop/Coder Desktop/Coder_DesktopApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}

// This function MUST eventually call `NSApp.reply(toApplicationShouldTerminate: true)`
// or return `.terminateNow`
func applicationShouldTerminate(_: NSApplication) -> NSApplication.TerminateReply {
if !settings.stopVPNOnQuit { return .terminateNow }
Task {
await vpn.quit()
await vpn.stop()
NSApp.reply(toApplicationShouldTerminate: true)
}
return .terminateLater
}
Expand Down
29 changes: 10 additions & 19 deletions Coder Desktop/Coder Desktop/NetworkExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,12 @@ 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 {
func hasNetworkExtensionConfig() async -> Bool {
do {
try await getTunnelManager()
neState = .disabled
_ = try await getTunnelManager()
return true
} catch {
neState = .unconfigured
return false
}
}

Expand Down Expand Up @@ -71,37 +70,29 @@ extension CoderVPNService {
}
}

func enableNetworkExtension() async {
func startTunnel() async {
do {
let tm = try await getTunnelManager()
if !tm.isEnabled {
tm.isEnabled = true
try await tm.saveToPreferences()
logger.debug("saved tunnel with enabled=true")
}
try tm.connection.startVPNTunnel()
} catch {
logger.error("enable network extension: \(error)")
logger.error("start tunnel: \(error)")
neState = .failed(error.localizedDescription)
return
}
logger.debug("enabled and started tunnel")
logger.debug("started tunnel")
neState = .enabled
}

func disableNetworkExtension() async {
func stopTunnel() async {
do {
let tm = try await getTunnelManager()
tm.connection.stopVPNTunnel()
tm.isEnabled = false

try await tm.saveToPreferences()
} catch {
logger.error("disable network extension: \(error)")
logger.error("stop tunnel: \(error)")
neState = .failed(error.localizedDescription)
return
}
logger.debug("saved tunnel with enabled=false")
logger.debug("stopped tunnel")
neState = .disabled
}

Expand Down
3 changes: 3 additions & 0 deletions Coder Desktop/Coder Desktop/State.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ class Settings: ObservableObject {
}
}

@AppStorage(Keys.stopVPNOnQuit) var stopVPNOnQuit = true

init(store: UserDefaults = .standard) {
self.store = store
_literalHeaders = Published(
Expand All @@ -116,6 +118,7 @@ class Settings: ObservableObject {
enum Keys {
static let useLiteralHeaders = "UseLiteralHeaders"
static let literalHeaders = "LiteralHeaders"
static let stopVPNOnQuit = "StopVPNOnQuit"
}
}

Expand Down
52 changes: 31 additions & 21 deletions Coder Desktop/Coder Desktop/VPNService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ enum VPNServiceError: Error, Equatable {
final class CoderVPNService: NSObject, VPNService {
var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "vpn")
lazy var xpc: VPNXPCInterface = .init(vpn: self)
var terminating = false
var workspaces: [UUID: String] = [:]

@Published var tunnelState: VPNServiceState = .disabled
Expand All @@ -68,8 +67,14 @@ final class CoderVPNService: NSObject, VPNService {
super.init()
installSystemExtension()
Task {
await loadNetworkExtension()
neState = if await hasNetworkExtensionConfig() {
.disabled
} else {
.unconfigured
}
}
xpc.connect()
xpc.getPeerState()
NotificationCenter.default.addObserver(
self,
selector: #selector(vpnDidUpdate(_:)),
Expand All @@ -82,6 +87,11 @@ final class CoderVPNService: NSObject, VPNService {
NotificationCenter.default.removeObserver(self)
}

func clearPeers() {
agents = [:]
workspaces = [:]
}

func start() async {
switch tunnelState {
case .disabled, .failed:
Expand All @@ -90,31 +100,18 @@ final class CoderVPNService: NSObject, VPNService {
return
}

await enableNetworkExtension()
// this ping is somewhat load bearing since it causes xpc to init
await startTunnel()
xpc.connect()
xpc.ping()
logger.debug("network extension enabled")
}

func stop() async {
guard tunnelState == .connected else { return }
await disableNetworkExtension()
await stopTunnel()
logger.info("network extension stopped")
}

// Instructs the service to stop the VPN and then quit once the stop event
// is read over XPC.
// MUST only be called from `NSApplicationDelegate.applicationShouldTerminate`
// MUST eventually call `NSApp.reply(toApplicationShouldTerminate: true)`
func quit() async {
guard tunnelState == .connected else {
NSApp.reply(toApplicationShouldTerminate: true)
return
}
terminating = true
await stop()
}

func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?) {
Task {
if let proto {
Expand Down Expand Up @@ -145,6 +142,22 @@ final class CoderVPNService: NSObject, VPNService {
}
}

func onExtensionPeerState(_ data: Data?) {
guard let data else {
logger.error("could not retrieve peer state from network extension, it may not be running")
return
}
logger.info("received network extension peer state")
do {
let msg = try Vpn_PeerUpdate(serializedBytes: data)
debugPrint(msg)
clearPeers()
applyPeerUpdate(with: msg)
Comment on lines +154 to +155
Copy link
Member

Choose a reason for hiding this comment

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

Doesn't this clear call make handling the deletedAgents and deletedWorkspaces in applyPeerUpdate unnecessary since there won't be any workspaces or agents left?

Copy link
Member Author

Choose a reason for hiding this comment

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

update.deletedAgents and update.deletedWorkspaces will always be empty on a getPeerState response, yeah. The Go code already keeps track of all known agents & workspaces, so as Dean suggested I think it'd be good to just send the full current state to the NE on any update.

} catch {
logger.error("failed to decode peer update \(error)")
}
}

func applyPeerUpdate(with update: Vpn_PeerUpdate) {
// Delete agents
update.deletedAgents
Expand Down Expand Up @@ -204,9 +217,6 @@ extension CoderVPNService {
}
switch connection.status {
case .disconnected:
if terminating {
NSApp.reply(toApplicationShouldTerminate: true)
}
connection.fetchLastDisconnectError { err in
self.tunnelState = if let err {
.failed(.internalError(err.localizedDescription))
Expand Down
6 changes: 6 additions & 0 deletions Coder Desktop/Coder Desktop/Views/Settings/GeneralTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@ import LaunchAtLogin
import SwiftUI

struct GeneralTab: View {
@EnvironmentObject var settings: Settings
var body: some View {
Form {
Section {
LaunchAtLogin.Toggle("Launch at Login")
}
Section {
Toggle(isOn: $settings.stopVPNOnQuit) {
Text("Stop VPN on Quit")
}
}
}.formStyle(.grouped)
}
}
Expand Down
22 changes: 18 additions & 4 deletions Coder Desktop/Coder Desktop/XPCInterface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@ import VPNLib
@objc final class VPNXPCInterface: NSObject, VPNXPCClientCallbackProtocol, @unchecked Sendable {
private var svc: CoderVPNService
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VPNXPCInterface")
private let xpc: VPNXPCProtocol
private var xpc: VPNXPCProtocol?

init(vpn: CoderVPNService) {
svc = vpn
super.init()
}

func connect() {
guard xpc == nil else {
return
}
let networkExtDict = Bundle.main.object(forInfoDictionaryKey: "NetworkExtension") as? [String: Any]
let machServiceName = networkExtDict?["NEMachServiceName"] as? String
let xpcConn = NSXPCConnection(machServiceName: machServiceName!)
Expand All @@ -21,30 +27,38 @@ import VPNLib
}
xpc = proxy

super.init()

xpcConn.exportedObject = self
xpcConn.invalidationHandler = { [logger] in
Task { @MainActor in
logger.error("XPC connection invalidated.")
self.xpc = nil
}
}
xpcConn.interruptionHandler = { [logger] in
Task { @MainActor in
logger.error("XPC connection interrupted.")
self.xpc = nil
}
}
xpcConn.resume()
}

func ping() {
xpc.ping {
xpc?.ping {
Task { @MainActor in
self.logger.info("Connected to NE over XPC")
}
}
}

func getPeerState() {
xpc?.getPeerState { data in
Task { @MainActor in
self.svc.onExtensionPeerState(data)
}
}
}

func onPeerUpdate(_ data: Data) {
Task { @MainActor in
svc.onExtensionPeerUpdate(data)
Expand Down
2 changes: 1 addition & 1 deletion Coder Desktop/VPN/Manager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ actor Manager {

// Retrieves the current state of all peers,
// as required when starting the app whilst the network extension is already running
func getPeerInfo() async throws(ManagerError) -> Vpn_PeerUpdate {
func getPeerState() async throws(ManagerError) -> Vpn_PeerUpdate {
logger.info("sending peer state request")
let resp: Vpn_TunnelMessage
do {
Expand Down
9 changes: 6 additions & 3 deletions Coder Desktop/VPN/XPCInterface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ import VPNLib
}
}

func getPeerInfo(with reply: @escaping () -> Void) {
// TODO: Retrieve from Manager
reply()
func getPeerState(with reply: @escaping (Data?) -> Void) {
let reply = CallbackWrapper(reply)
Task {
let data = try? await manager?.getPeerState().serializedData()
reply(data)
}
}

func ping(with reply: @escaping () -> Void) {
Expand Down
2 changes: 1 addition & 1 deletion Coder Desktop/VPNLib/XPC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Foundation

@preconcurrency
@objc public protocol VPNXPCProtocol {
func getPeerInfo(with reply: @escaping () -> Void)
func getPeerState(with reply: @escaping (Data?) -> Void)
func ping(with reply: @escaping () -> Void)
}

Expand Down