Skip to content

feat: pass agent updates to UI #35

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
Feb 10, 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
41 changes: 25 additions & 16 deletions Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift
Original file line number Diff line number Diff line change
@@ -4,21 +4,30 @@ import SwiftUI
@MainActor
final class PreviewVPN: Coder_Desktop.VPNService {
@Published var state: Coder_Desktop.VPNServiceState = .disabled
@Published var agents: [Coder_Desktop.Agent] = [
Agent(id: UUID(), name: "dogfood2", status: .error, copyableDNS: "asdf.coder", workspaceName: "dogfood2"),
Agent(id: UUID(), name: "testing-a-very-long-name", status: .okay, copyableDNS: "asdf.coder",
workspaceName: "testing-a-very-long-name"),
Agent(id: UUID(), name: "opensrc", status: .warn, copyableDNS: "asdf.coder", workspaceName: "opensrc"),
Agent(id: UUID(), name: "gvisor", status: .off, copyableDNS: "asdf.coder", workspaceName: "gvisor"),
Agent(id: UUID(), name: "example", status: .off, copyableDNS: "asdf.coder", workspaceName: "example"),
Agent(id: UUID(), name: "dogfood2", status: .error, copyableDNS: "asdf.coder", workspaceName: "dogfood2"),
Agent(id: UUID(), name: "testing-a-very-long-name", status: .okay, copyableDNS: "asdf.coder",
workspaceName: "testing-a-very-long-name"),
Agent(id: UUID(), name: "opensrc", status: .warn, copyableDNS: "asdf.coder", workspaceName: "opensrc"),
Agent(id: UUID(), name: "gvisor", status: .off, copyableDNS: "asdf.coder", workspaceName: "gvisor"),
Agent(id: UUID(), name: "example", status: .off, copyableDNS: "asdf.coder", workspaceName: "example"),
@Published var agents: [UUID: Coder_Desktop.Agent] = [
UUID(): Agent(id: UUID(), name: "dev", status: .error, copyableDNS: "asdf.coder", wsName: "dogfood2",
wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .okay, copyableDNS: "asdf.coder",
wsName: "testing-a-very-long-name", wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .warn, copyableDNS: "asdf.coder", wsName: "opensrc",
wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "gvisor",
wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "example",
wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .error, copyableDNS: "asdf.coder", wsName: "dogfood2",
wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .okay, copyableDNS: "asdf.coder",
wsName: "testing-a-very-long-name", wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .warn, copyableDNS: "asdf.coder", wsName: "opensrc",
wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "gvisor",
wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "example",
wsID: UUID()),
]
let shouldFail: Bool
let longError = "This is a long error to test the UI with long error messages"

init(shouldFail: Bool = false) {
self.shouldFail = shouldFail
@@ -35,10 +44,10 @@ final class PreviewVPN: Coder_Desktop.VPNService {
do {
try await Task.sleep(for: .seconds(5))
} catch {
state = .failed(.longTestError)
state = .failed(.internalError(longError))
return
}
state = shouldFail ? .failed(.longTestError) : .connected
state = shouldFail ? .failed(.internalError(longError)) : .connected
}
defer { startTask = nil }
await startTask?.value
@@ -57,7 +66,7 @@ final class PreviewVPN: Coder_Desktop.VPNService {
do {
try await Task.sleep(for: .seconds(5))
} catch {
state = .failed(.longTestError)
state = .failed(.internalError(longError))
return
}
state = .disabled
118 changes: 94 additions & 24 deletions Coder Desktop/Coder Desktop/VPNService.swift
Original file line number Diff line number Diff line change
@@ -2,14 +2,12 @@ import NetworkExtension
import os
import SwiftUI
import VPNLib
import VPNXPC

@MainActor
protocol VPNService: ObservableObject {
var state: VPNServiceState { get }
var agents: [Agent] { get }
var agents: [UUID: Agent] { get }
func start() async
// Stop must be idempotent
func stop() async
func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?)
}
@@ -26,12 +24,9 @@ enum VPNServiceError: Error, Equatable {
case internalError(String)
case systemExtensionError(SystemExtensionState)
case networkExtensionError(NetworkExtensionState)
case longTestError

var description: String {
switch self {
case .longTestError:
"This is a long error to test the UI with long errors"
case let .internalError(description):
"Internal Error: \(description)"
case let .systemExtensionError(state):
@@ -47,6 +42,7 @@ 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
@Published var sysExtnState: SystemExtensionState = .uninstalled
@@ -61,7 +57,7 @@ final class CoderVPNService: NSObject, VPNService {
return tunnelState
}

@Published var agents: [Agent] = []
@Published var agents: [UUID: Agent] = [:]

// systemExtnDelegate holds a reference to the SystemExtensionDelegate so that it doesn't get
// garbage collected while the OSSystemExtensionRequest is in flight, since the OS framework
@@ -74,6 +70,16 @@ final class CoderVPNService: NSObject, VPNService {
Task {
await loadNetworkExtension()
}
NotificationCenter.default.addObserver(
self,
selector: #selector(vpnDidUpdate(_:)),
name: .NEVPNStatusDidChange,
object: nil
)
}

deinit {
NotificationCenter.default.removeObserver(self)
}

func start() async {
@@ -84,16 +90,14 @@ final class CoderVPNService: NSObject, VPNService {
return
}

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

func stop() async {
guard tunnelState == .connected else { return }
tunnelState = .disconnecting
await disableNetworkExtension()
logger.info("network extension stopped")
}
@@ -131,31 +135,97 @@ final class CoderVPNService: NSObject, VPNService {
}

func onExtensionPeerUpdate(_ data: Data) {
// TODO: handle peer update
logger.info("network extension peer update")
do {
let msg = try Vpn_TunnelMessage(serializedBytes: data)
let msg = try Vpn_PeerUpdate(serializedBytes: data)
debugPrint(msg)
applyPeerUpdate(with: msg)
} catch {
logger.error("failed to decode peer update \(error)")
}
}

func onExtensionStart() {
logger.info("network extension reported started")
tunnelState = .connected
}
func applyPeerUpdate(with update: Vpn_PeerUpdate) {
// Delete agents
update.deletedAgents
.compactMap { UUID(uuidData: $0.id) }
.forEach { agentID in
agents[agentID] = nil
}
update.deletedWorkspaces
.compactMap { UUID(uuidData: $0.id) }
.forEach { workspaceID in
workspaces[workspaceID] = nil
for (id, agent) in agents where agent.wsID == workspaceID {
agents[id] = nil
}
}

func onExtensionStop() {
logger.info("network extension reported stopped")
tunnelState = .disabled
if terminating {
NSApp.reply(toApplicationShouldTerminate: true)
// Update workspaces
for workspaceProto in update.upsertedWorkspaces {
if let workspaceID = UUID(uuidData: workspaceProto.id) {
workspaces[workspaceID] = workspaceProto.name
}
}

for agentProto in update.upsertedAgents {
guard let agentID = UUID(uuidData: agentProto.id) else {
continue
}
guard let workspaceID = UUID(uuidData: agentProto.workspaceID) else {
continue
}
let workspaceName = workspaces[workspaceID] ?? "Unknown Workspace"
let newAgent = Agent(
id: agentID,
name: agentProto.name,
// If last handshake was not within last five minutes, the agent is unhealthy
status: agentProto.lastHandshake.date > Date.now.addingTimeInterval(-300) ? .okay : .off,
copyableDNS: agentProto.fqdn.first ?? "UNKNOWN",
wsName: workspaceName,
wsID: workspaceID
)

// An existing agent with the same name, belonging to the same workspace
// is from a previous workspace build, and should be removed.
agents
.filter { $0.value.name == agentProto.name && $0.value.wsID == workspaceID }
.forEach { agents[$0.key] = nil }

agents[agentID] = newAgent
}
}
}

func onExtensionError(_ error: NSError) {
logger.error("network extension reported error: \(error)")
tunnelState = .failed(.internalError(error.localizedDescription))
extension CoderVPNService {
@objc private func vpnDidUpdate(_ notification: Notification) {
Copy link
Member Author

Choose a reason for hiding this comment

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

macOS can tell us when the Network Extension changes state, including if there was an error that caused it to disconnect, e.g. NE crashes, ptp.cancelTunnelWithError, start/stop completionHandler(err)

guard let connection = notification.object as? NETunnelProviderSession else {
return
}
switch connection.status {
case .disconnected:
if terminating {
NSApp.reply(toApplicationShouldTerminate: true)
}
connection.fetchLastDisconnectError { err in
self.tunnelState = if let err {
.failed(.internalError(err.localizedDescription))
} else {
.disabled
}
}
case .connecting:
tunnelState = .connecting
case .connected:
tunnelState = .connected
case .reasserting:
tunnelState = .connecting
case .disconnecting:
tunnelState = .disconnecting
case .invalid:
tunnelState = .failed(.networkExtensionError(.unconfigured))
@unknown default:
tunnelState = .disabled
}
}
}
39 changes: 26 additions & 13 deletions Coder Desktop/Coder Desktop/Views/Agent.swift
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
import SwiftUI

struct Agent: Identifiable, Equatable {
struct Agent: Identifiable, Equatable, Comparable {
let id: UUID
let name: String
let status: AgentStatus
let copyableDNS: String
let workspaceName: String
let wsName: String
let wsID: UUID

// Agents are sorted by status, and then by name
static func < (lhs: Agent, rhs: Agent) -> Bool {
if lhs.status != rhs.status {
return lhs.status < rhs.status
}
return lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending
}
}

enum AgentStatus: Equatable {
case okay
case warn
case error
case off
enum AgentStatus: Int, Equatable, Comparable {
case okay = 0
case warn = 1
case error = 2
case off = 3

public var color: Color {
switch self {
@@ -22,16 +31,20 @@ enum AgentStatus: Equatable {
case .off: .gray
}
}

static func < (lhs: AgentStatus, rhs: AgentStatus) -> Bool {
lhs.rawValue < rhs.rawValue
}
}

struct AgentRowView: View {
let workspace: Agent
let agent: Agent
let baseAccessURL: URL
@State private var nameIsSelected: Bool = false
@State private var copyIsSelected: Bool = false

private var fmtWsName: AttributedString {
var formattedName = AttributedString(workspace.name)
var formattedName = AttributedString(agent.wsName)
formattedName.foregroundColor = .primary
var coderPart = AttributedString(".coder")
coderPart.foregroundColor = .gray
@@ -41,7 +54,7 @@ struct AgentRowView: View {

private var wsURL: URL {
// TODO: CoderVPN currently only supports owned workspaces
baseAccessURL.appending(path: "@me").appending(path: workspace.workspaceName)
baseAccessURL.appending(path: "@me").appending(path: agent.wsName)
}

var body: some View {
@@ -50,10 +63,10 @@ struct AgentRowView: View {
HStack(spacing: Theme.Size.trayPadding) {
ZStack {
Circle()
.fill(workspace.status.color.opacity(0.4))
.fill(agent.status.color.opacity(0.4))
.frame(width: 12, height: 12)
Circle()
.fill(workspace.status.color.opacity(1.0))
.fill(agent.status.color.opacity(1.0))
.frame(width: 7, height: 7)
}
Text(fmtWsName).lineLimit(1).truncationMode(.tail)
@@ -69,7 +82,7 @@ struct AgentRowView: View {
}.buttonStyle(.plain)
Button {
// TODO: Proper clipboard abstraction
NSPasteboard.general.setString(workspace.copyableDNS, forType: .string)
NSPasteboard.general.setString(agent.copyableDNS, forType: .string)
} label: {
Image(systemName: "doc.on.doc")
.symbolVariant(.fill)
9 changes: 5 additions & 4 deletions Coder Desktop/Coder Desktop/Views/Agents.swift
Original file line number Diff line number Diff line change
@@ -10,11 +10,12 @@ struct Agents<VPN: VPNService, S: Session>: View {

var body: some View {
Group {
// Workspaces List
// Agents List
if vpn.state == .connected {
let visibleData = viewAll ? vpn.agents : Array(vpn.agents.prefix(defaultVisibleRows))
ForEach(visibleData, id: \.id) { workspace in
AgentRowView(workspace: workspace, baseAccessURL: session.baseAccessURL!)
let sortedAgents = vpn.agents.values.sorted()
let visibleData = viewAll ? sortedAgents[...] : sortedAgents.prefix(defaultVisibleRows)
ForEach(visibleData, id: \.id) { agent in
AgentRowView(agent: agent, baseAccessURL: session.baseAccessURL!)
.padding(.horizontal, Theme.Size.trayMargin)
}
if vpn.agents.count > defaultVisibleRows {
19 changes: 19 additions & 0 deletions Coder Desktop/Coder Desktop/Views/Util.swift
Original file line number Diff line number Diff line change
@@ -12,3 +12,22 @@ final class Inspection<V> {
}
}
}

extension UUID {
var uuidData: Data {
withUnsafePointer(to: uuid) {
Data(bytes: $0, count: MemoryLayout.size(ofValue: uuid))
}
}

init?(uuidData: Data) {
guard uuidData.count == 16 else {
return nil
}
var uuid: uuid_t = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
Copy link
Member Author

Choose a reason for hiding this comment

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

Very funny: there's no fixed size arrays in Swift (probably cause of objc), so uuid_t is just a tuple of 16 u8s.
Also, the proposal to add one includes calling that new type a Vector https://forums.swift.org/t/second-review-se-0453-vector-a-fixed-size-array/76412/20

withUnsafeMutableBytes(of: &uuid) {
$0.copyBytes(from: uuidData)
}
self.init(uuid: uuid)
}
}
21 changes: 2 additions & 19 deletions Coder Desktop/Coder Desktop/XPCInterface.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation
import NetworkExtension
import os
import VPNXPC
import VPNLib

@objc final class VPNXPCInterface: NSObject, VPNXPCClientCallbackProtocol, @unchecked Sendable {
private var svc: CoderVPNService
@@ -49,22 +50,4 @@ import VPNXPC
svc.onExtensionPeerUpdate(data)
}
}

func onStart() {
Task { @MainActor in
svc.onExtensionStart()
}
}

func onStop() {
Task { @MainActor in
svc.onExtensionStop()
}
}

func onError(_ err: NSError) {
Task { @MainActor in
svc.onExtensionError(err)
}
}
}
15 changes: 9 additions & 6 deletions Coder Desktop/Coder DesktopTests/AgentsTests.swift
Original file line number Diff line number Diff line change
@@ -18,16 +18,18 @@ struct AgentsTests {
view = sut.environmentObject(vpn).environmentObject(session)
}

private func createMockAgents(count: Int) -> [Agent] {
(1 ... count).map {
Agent(
private func createMockAgents(count: Int) -> [UUID: Agent] {
Dictionary(uniqueKeysWithValues: (1 ... count).map {
let agent = Agent(
id: UUID(),
name: "a\($0)",
name: "dev",
status: .okay,
copyableDNS: "a\($0).example.com",
workspaceName: "w\($0)"
wsName: "a\($0)",
wsID: UUID()
)
}
return (agent.id, agent)
})
}

@Test
@@ -46,6 +48,7 @@ struct AgentsTests {

let forEach = try view.inspect().find(ViewType.ForEach.self)
#expect(forEach.count == Theme.defaultVisibleAgents)
// Agents are sorted by status, and then by name in alphabetical order
#expect(throws: Never.self) { try view.inspect().find(link: "a1.coder") }
}

2 changes: 1 addition & 1 deletion Coder Desktop/Coder DesktopTests/Util.swift
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@ import ViewInspector
class MockVPNService: VPNService, ObservableObject {
@Published var state: Coder_Desktop.VPNServiceState = .disabled
@Published var baseAccessURL: URL = .init(string: "https://dev.coder.com")!
@Published var agents: [Coder_Desktop.Agent] = []
@Published var agents: [UUID: Coder_Desktop.Agent] = [:]
var onStart: (() async -> Void)?
var onStop: (() async -> Void)?

2 changes: 1 addition & 1 deletion Coder Desktop/Coder DesktopTests/VPNMenuTests.swift
Original file line number Diff line number Diff line change
@@ -111,7 +111,7 @@ struct VPNMenuTests {
#expect(try !toggle.isOn())

vpn.onStart = {
vpn.state = .failed(.longTestError)
vpn.state = .failed(.internalError("This is a long error message!"))
}
await vpn.start()

116 changes: 116 additions & 0 deletions Coder Desktop/Coder DesktopTests/VPNServiceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
@testable import Coder_Desktop
import Testing
@testable import VPNLib

@MainActor
@Suite
struct CoderVPNServiceTests {
let service = CoderVPNService()

init() {
service.workspaces = [:]
service.agents = [:]
}

@Test
func testApplyPeerUpdate_upsertsAgents() async throws {
let agentID = UUID()
let workspaceID = UUID()
service.workspaces[workspaceID] = "foo"

let update = Vpn_PeerUpdate.with {
$0.upsertedAgents = [Vpn_Agent.with {
$0.id = agentID.uuidData
$0.workspaceID = workspaceID.uuidData
$0.name = "dev"
$0.lastHandshake = .init(date: Date.now)
$0.fqdn = ["foo.coder"]
}]
}

service.applyPeerUpdate(with: update)

let agent = try #require(service.agents[agentID])
#expect(agent.name == "dev")
#expect(agent.wsID == workspaceID)
#expect(agent.wsName == "foo")
#expect(agent.copyableDNS == "foo.coder")
#expect(agent.status == .okay)
}

@Test
func testApplyPeerUpdate_deletesAgentsAndWorkspaces() async throws {
let agentID = UUID()
let workspaceID = UUID()

service.agents[agentID] = Agent(
id: agentID, name: "agent1", status: .okay,
copyableDNS: "foo.coder", wsName: "foo", wsID: workspaceID
)
service.workspaces[workspaceID] = "foo"

let update = Vpn_PeerUpdate.with {
$0.deletedAgents = [Vpn_Agent.with { $0.id = agentID.uuidData }]
$0.deletedWorkspaces = [Vpn_Workspace.with { $0.id = workspaceID.uuidData }]
}

service.applyPeerUpdate(with: update)

#expect(service.agents[agentID] == nil)
#expect(service.workspaces[workspaceID] == nil)
}

@Test
func testApplyPeerUpdate_unhealthyAgent() async throws {
let agentID = UUID()
let workspaceID = UUID()
service.workspaces[workspaceID] = "foo"

let update = Vpn_PeerUpdate.with {
$0.upsertedAgents = [Vpn_Agent.with {
$0.id = agentID.uuidData
$0.workspaceID = workspaceID.uuidData
$0.name = "agent1"
$0.lastHandshake = .init(date: Date.now.addingTimeInterval(-600))
$0.fqdn = ["foo.coder"]
}]
}

service.applyPeerUpdate(with: update)

let agent = try #require(service.agents[agentID])
#expect(agent.status == .off)
}

@Test
func testApplyPeerUpdate_replaceOldAgent() async throws {
let workspaceID = UUID()
let oldAgentID = UUID()
let newAgentID = UUID()
service.workspaces[workspaceID] = "foo"

service.agents[oldAgentID] = Agent(
id: oldAgentID, name: "agent1", status: .off,
copyableDNS: "foo.coder", wsName: "foo", wsID: workspaceID
)

let update = Vpn_PeerUpdate.with {
$0.upsertedAgents = [Vpn_Agent.with {
$0.id = newAgentID.uuidData
$0.workspaceID = workspaceID.uuidData
$0.name = "agent1" // Same name as old agent
$0.lastHandshake = .init(date: Date.now)
$0.fqdn = ["foo.coder"]
}]
}

service.applyPeerUpdate(with: update)

#expect(service.agents[oldAgentID] == nil)
let newAgent = try #require(service.agents[newAgentID])
#expect(newAgent.name == "agent1")
#expect(newAgent.wsID == workspaceID)
#expect(newAgent.copyableDNS == "foo.coder")
#expect(newAgent.status == .okay)
}
}
5 changes: 3 additions & 2 deletions Coder Desktop/Coder DesktopTests/VPNStateTests.swift
Original file line number Diff line number Diff line change
@@ -55,12 +55,13 @@ struct VPNStateTests {

@Test
func testFailedState() async throws {
vpn.state = .failed(.longTestError)
let errMsg = "Internal error occured!"
vpn.state = .failed(.internalError(errMsg))

try await ViewHosting.host(view.environmentObject(vpn)) {
try await sut.inspection.inspect { view in
let text = try view.find(ViewType.Text.self)
#expect(try text.string() == VPNServiceError.longTestError.description)
#expect(try text.string() == "Internal Error: \(errMsg)")
}
}
}
11 changes: 3 additions & 8 deletions Coder Desktop/VPN/Manager.swift
Original file line number Diff line number Diff line change
@@ -2,7 +2,6 @@ import CoderSDK
import NetworkExtension
import os
import VPNLib
import VPNXPC

actor Manager {
let ptp: PacketTunnelProvider
@@ -86,16 +85,12 @@ actor Manager {
} catch {
logger.error("tunnel read loop failed: \(error.localizedDescription, privacy: .public)")
try await tunnelHandle.close()
if let conn = globalXPCListenerDelegate.getActiveConnection() {
conn.onError(error as NSError)
}
ptp.cancelTunnelWithError(error)
return
}
logger.info("tunnel read loop exited")
try await tunnelHandle.close()
if let conn = globalXPCListenerDelegate.getActiveConnection() {
conn.onStop()
}
ptp.cancelTunnelWithError(nil)
}

func handleMessage(_ msg: Vpn_TunnelMessage) {
@@ -105,7 +100,7 @@ actor Manager {
}
switch msgType {
case .peerUpdate:
if let conn = globalXPCListenerDelegate.getActiveConnection() {
if let conn = globalXPCListenerDelegate.conn {
do {
let data = try msg.peerUpdate.serializedData()
conn.onPeerUpdate(data)
20 changes: 2 additions & 18 deletions Coder Desktop/VPN/PacketTunnelProvider.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import NetworkExtension
import os
import VPNLib
import VPNXPC

/* From <sys/kern_control.h> */
let CTLIOCGINFO: UInt = 0xC064_4E03
@@ -77,23 +76,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
apiToken: token, serverUrl: .init(string: baseAccessURL)!
)
)
globalXPCListenerDelegate.vpnXPCInterface.setManager(manager)
globalXPCListenerDelegate.vpnXPCInterface.manager = manager
logger.debug("starting vpn")
try await manager!.startVPN()
logger.info("vpn started")
if let conn = globalXPCListenerDelegate.getActiveConnection() {
conn.onStart()
} else {
logger.info("no active XPC connection")
}
completionHandler(nil)
} catch {
logger.error("error starting manager: \(error.description, privacy: .public)")
if let conn = globalXPCListenerDelegate.getActiveConnection() {
conn.onError(error as NSError)
} else {
logger.info("no active XPC connection")
}
completionHandler(error as NSError)
}
}
@@ -116,12 +105,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
} catch {
logger.error("error stopping manager: \(error.description, privacy: .public)")
}
if let conn = globalXPCListenerDelegate.getActiveConnection() {
conn.onStop()
} else {
logger.info("no active XPC connection")
}
globalXPCListenerDelegate.vpnXPCInterface.setManager(nil)
globalXPCListenerDelegate.vpnXPCInterface.manager = nil
completionHandler()
}
self.manager = nil
27 changes: 13 additions & 14 deletions Coder Desktop/VPN/XPCInterface.swift
Original file line number Diff line number Diff line change
@@ -1,28 +1,27 @@
import Foundation
import os.log
import VPNLib
import VPNXPC

@objc final class XPCInterface: NSObject, VPNXPCProtocol, @unchecked Sendable {
private var manager: Manager?
private var lockedManager: Manager?
private let managerLock = NSLock()
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VPNXPCInterface")

func setManager(_ newManager: Manager?) {
managerLock.lock()
defer { managerLock.unlock() }
manager = newManager
}

func getManager() -> Manager? {
managerLock.lock()
defer { managerLock.unlock() }
let m = manager

return m
var manager: Manager? {
get {
managerLock.lock()
defer { managerLock.unlock() }
return lockedManager
}
set {
managerLock.lock()
defer { managerLock.unlock() }
lockedManager = newValue
}
}

func getPeerInfo(with reply: @escaping () -> Void) {
// TODO: Retrieve from Manager
reply()
}

12 changes: 6 additions & 6 deletions Coder Desktop/VPN/main.swift
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import Foundation
import NetworkExtension
import os
import VPNXPC
import VPNLib

let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "provider")

final class XPCListenerDelegate: NSObject, NSXPCListenerDelegate, @unchecked Sendable {
let vpnXPCInterface = XPCInterface()
var activeConnection: NSXPCConnection?
var connMutex: NSLock = .init()
private var activeConnection: NSXPCConnection?
private var connMutex: NSLock = .init()

func getActiveConnection() -> VPNXPCClientCallbackProtocol? {
var conn: VPNXPCClientCallbackProtocol? {
connMutex.lock()
defer { connMutex.unlock() }

let client = activeConnection?.remoteObjectProxy as? VPNXPCClientCallbackProtocol
return client
let conn = activeConnection?.remoteObjectProxy as? VPNXPCClientCallbackProtocol
return conn
}

func setActiveConnection(_ connection: NSXPCConnection?) {
9 changes: 9 additions & 0 deletions Coder Desktop/VPNLib/Convert.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import NetworkExtension
import os
import SwiftProtobuf

public func convertDnsSettings(_ req: Vpn_NetworkSettingsRequest.DNSSettings) -> NEDNSSettings {
let dnsSettings = NEDNSSettings(servers: req.servers)
@@ -59,3 +60,11 @@ public func convertIPv6Settings(_ req: Vpn_NetworkSettingsRequest.IPv6Settings)
}
return ipv6Settings
}

extension Google_Protobuf_Timestamp {
var date: Date {
let seconds = TimeInterval(seconds)
let nanos = TimeInterval(nanos) / 1_000_000_000
return Date(timeIntervalSince1970: seconds + nanos)
}
}
Original file line number Diff line number Diff line change
@@ -8,9 +8,6 @@ import Foundation

@preconcurrency
@objc public protocol VPNXPCClientCallbackProtocol {
/// Called when the server has a status update to share
// data is a serialized `Vpn_PeerUpdate`
func onPeerUpdate(_ data: Data)
func onStart()
func onStop()
func onError(_ err: NSError)
}
67 changes: 64 additions & 3 deletions Coder Desktop/VPNLib/vpn.pb.swift
Original file line number Diff line number Diff line change
@@ -393,7 +393,7 @@ public struct Vpn_Agent: @unchecked Sendable {
/// UUID
public var workspaceID: Data = Data()

public var fqdn: String = String()
public var fqdn: [String] = []

public var ipAddrs: [String] = []

@@ -597,8 +597,25 @@ public struct Vpn_StartRequest: Sendable {

public var apiToken: String = String()

public var headers: [Vpn_StartRequest.Header] = []

public var unknownFields = SwiftProtobuf.UnknownStorage()

/// Additional HTTP headers added to all requests
public struct Header: Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages.

public var name: String = String()

public var value: String = String()

public var unknownFields = SwiftProtobuf.UnknownStorage()

public init() {}
}

public init() {}
}

@@ -1176,7 +1193,7 @@ extension Vpn_Agent: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementation
case 1: try { try decoder.decodeSingularBytesField(value: &self.id) }()
case 2: try { try decoder.decodeSingularStringField(value: &self.name) }()
case 3: try { try decoder.decodeSingularBytesField(value: &self.workspaceID) }()
case 4: try { try decoder.decodeSingularStringField(value: &self.fqdn) }()
case 4: try { try decoder.decodeRepeatedStringField(value: &self.fqdn) }()
case 5: try { try decoder.decodeRepeatedStringField(value: &self.ipAddrs) }()
case 6: try { try decoder.decodeSingularMessageField(value: &self._lastHandshake) }()
default: break
@@ -1199,7 +1216,7 @@ extension Vpn_Agent: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementation
try visitor.visitSingularBytesField(value: self.workspaceID, fieldNumber: 3)
}
if !self.fqdn.isEmpty {
try visitor.visitSingularStringField(value: self.fqdn, fieldNumber: 4)
try visitor.visitRepeatedStringField(value: self.fqdn, fieldNumber: 4)
}
if !self.ipAddrs.isEmpty {
try visitor.visitRepeatedStringField(value: self.ipAddrs, fieldNumber: 5)
@@ -1632,6 +1649,7 @@ extension Vpn_StartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme
1: .standard(proto: "tunnel_file_descriptor"),
2: .standard(proto: "coder_url"),
3: .standard(proto: "api_token"),
4: .same(proto: "headers"),
]

public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
@@ -1643,6 +1661,7 @@ extension Vpn_StartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme
case 1: try { try decoder.decodeSingularInt32Field(value: &self.tunnelFileDescriptor) }()
case 2: try { try decoder.decodeSingularStringField(value: &self.coderURL) }()
case 3: try { try decoder.decodeSingularStringField(value: &self.apiToken) }()
case 4: try { try decoder.decodeRepeatedMessageField(value: &self.headers) }()
default: break
}
}
@@ -1658,13 +1677,55 @@ extension Vpn_StartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme
if !self.apiToken.isEmpty {
try visitor.visitSingularStringField(value: self.apiToken, fieldNumber: 3)
}
if !self.headers.isEmpty {
try visitor.visitRepeatedMessageField(value: self.headers, fieldNumber: 4)
}
try unknownFields.traverse(visitor: &visitor)
}

public static func ==(lhs: Vpn_StartRequest, rhs: Vpn_StartRequest) -> Bool {
if lhs.tunnelFileDescriptor != rhs.tunnelFileDescriptor {return false}
if lhs.coderURL != rhs.coderURL {return false}
if lhs.apiToken != rhs.apiToken {return false}
if lhs.headers != rhs.headers {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}

extension Vpn_StartRequest.Header: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
public static let protoMessageName: String = Vpn_StartRequest.protoMessageName + ".Header"
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1: .same(proto: "name"),
2: .same(proto: "value"),
]

public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeSingularStringField(value: &self.name) }()
case 2: try { try decoder.decodeSingularStringField(value: &self.value) }()
default: break
}
}
}

public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
if !self.name.isEmpty {
try visitor.visitSingularStringField(value: self.name, fieldNumber: 1)
}
if !self.value.isEmpty {
try visitor.visitSingularStringField(value: self.value, fieldNumber: 2)
}
try unknownFields.traverse(visitor: &visitor)
}

public static func ==(lhs: Vpn_StartRequest.Header, rhs: Vpn_StartRequest.Header) -> Bool {
if lhs.name != rhs.name {return false}
if lhs.value != rhs.value {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
250 changes: 128 additions & 122 deletions Coder Desktop/VPNLib/vpn.proto
Original file line number Diff line number Diff line change
@@ -17,55 +17,55 @@ package vpn;
// msg_id which it sets on the request, the responder sets response_to that msg_id on the response
// message
message RPC {
uint64 msg_id = 1;
uint64 response_to = 2;
uint64 msg_id = 1;
uint64 response_to = 2;
}

// ManagerMessage is a message from the manager (to the tunnel).
message ManagerMessage {
RPC rpc = 1;
oneof msg {
GetPeerUpdate get_peer_update = 2;
NetworkSettingsResponse network_settings = 3;
StartRequest start = 4;
StopRequest stop = 5;
}
RPC rpc = 1;
oneof msg {
GetPeerUpdate get_peer_update = 2;
NetworkSettingsResponse network_settings = 3;
StartRequest start = 4;
StopRequest stop = 5;
}
}

// TunnelMessage is a message from the tunnel (to the manager).
message TunnelMessage {
RPC rpc = 1;
oneof msg {
Log log = 2;
PeerUpdate peer_update = 3;
NetworkSettingsRequest network_settings = 4;
StartResponse start = 5;
StopResponse stop = 6;
}
RPC rpc = 1;
oneof msg {
Log log = 2;
PeerUpdate peer_update = 3;
NetworkSettingsRequest network_settings = 4;
StartResponse start = 5;
StopResponse stop = 6;
}
}

// Log is a log message generated by the tunnel. The manager should log it to the system log. It is
// one-way tunnel -> manager with no response.
message Log {
enum Level {
// these are designed to match slog levels
DEBUG = 0;
INFO = 1;
WARN = 2;
ERROR = 3;
CRITICAL = 4;
FATAL = 5;
}
Level level = 1;

string message = 2;
repeated string logger_names = 3;

message Field {
string name = 1;
string value = 2;
}
repeated Field fields = 4;
enum Level {
// these are designed to match slog levels
DEBUG = 0;
INFO = 1;
WARN = 2;
ERROR = 3;
CRITICAL = 4;
FATAL = 5;
}
Level level = 1;

string message = 2;
repeated string logger_names = 3;

message Field {
string name = 1;
string value = 2;
}
repeated Field fields = 4;
}

// GetPeerUpdate asks for a PeerUpdate with a full set of data.
@@ -75,115 +75,121 @@ message GetPeerUpdate {}
// response to GetPeerUpdate (which dumps the full set). It is also generated on any changes (not in
// response to any request).
message PeerUpdate {
repeated Workspace upserted_workspaces = 1;
repeated Agent upserted_agents = 2;
repeated Workspace deleted_workspaces = 3;
repeated Agent deleted_agents = 4;
repeated Workspace upserted_workspaces = 1;
repeated Agent upserted_agents = 2;
repeated Workspace deleted_workspaces = 3;
repeated Agent deleted_agents = 4;
}

message Workspace {
bytes id = 1; // UUID
string name = 2;

enum Status {
UNKNOWN = 0;
PENDING = 1;
STARTING = 2;
RUNNING = 3;
STOPPING = 4;
STOPPED = 5;
FAILED = 6;
CANCELING = 7;
CANCELED = 8;
DELETING = 9;
DELETED = 10;
}
Status status = 3;
bytes id = 1; // UUID
string name = 2;

enum Status {
UNKNOWN = 0;
PENDING = 1;
STARTING = 2;
RUNNING = 3;
STOPPING = 4;
STOPPED = 5;
FAILED = 6;
CANCELING = 7;
CANCELED = 8;
DELETING = 9;
DELETED = 10;
}
Status status = 3;
}

message Agent {
bytes id = 1; // UUID
string name = 2;
bytes workspace_id = 3; // UUID
string fqdn = 4;
repeated string ip_addrs = 5;
// last_handshake is the primary indicator of whether we are connected to a peer. Zero value or
// anything longer than 5 minutes ago means there is a problem.
google.protobuf.Timestamp last_handshake = 6;
bytes id = 1; // UUID
string name = 2;
bytes workspace_id = 3; // UUID
repeated string fqdn = 4;
repeated string ip_addrs = 5;
// last_handshake is the primary indicator of whether we are connected to a peer. Zero value or
// anything longer than 5 minutes ago means there is a problem.
google.protobuf.Timestamp last_handshake = 6;
}

// NetworkSettingsRequest is based on
// https://developer.apple.com/documentation/networkextension/nepackettunnelnetworksettings for
// macOS. It is a request/response message with response NetworkSettingsResponse
message NetworkSettingsRequest {
uint32 tunnel_overhead_bytes = 1;
uint32 mtu = 2;

message DNSSettings {
repeated string servers = 1;
repeated string search_domains = 2;
// domain_name is the primary domain name of the tunnel
string domain_name = 3;
repeated string match_domains = 4;
// match_domains_no_search specifies if the domains in the matchDomains list should not be
// appended to the resolver’s list of search domains.
bool match_domains_no_search = 5;
}
DNSSettings dns_settings = 3;

string tunnel_remote_address = 4;

message IPv4Settings {
repeated string addrs = 1;
repeated string subnet_masks = 2;
// router is the next-hop router in dotted-decimal format
string router = 3;

message IPv4Route {
string destination = 1;
string mask = 2;
// router is the next-hop router in dotted-decimal format
string router = 3;
}
repeated IPv4Route included_routes = 4;
repeated IPv4Route excluded_routes = 5;
}
IPv4Settings ipv4_settings = 5;

message IPv6Settings {
repeated string addrs = 1;
repeated uint32 prefix_lengths = 2;

message IPv6Route {
string destination = 1;
uint32 prefix_length = 2;
// router is the address of the next-hop
string router = 3;
}
repeated IPv6Route included_routes = 3;
repeated IPv6Route excluded_routes = 4;
}
IPv6Settings ipv6_settings = 6;
uint32 tunnel_overhead_bytes = 1;
uint32 mtu = 2;

message DNSSettings {
repeated string servers = 1;
repeated string search_domains = 2;
// domain_name is the primary domain name of the tunnel
string domain_name = 3;
repeated string match_domains = 4;
// match_domains_no_search specifies if the domains in the matchDomains list should not be
// appended to the resolver’s list of search domains.
bool match_domains_no_search = 5;
}
DNSSettings dns_settings = 3;

string tunnel_remote_address = 4;

message IPv4Settings {
repeated string addrs = 1;
repeated string subnet_masks = 2;
// router is the next-hop router in dotted-decimal format
string router = 3;

message IPv4Route {
string destination = 1;
string mask = 2;
// router is the next-hop router in dotted-decimal format
string router = 3;
}
repeated IPv4Route included_routes = 4;
repeated IPv4Route excluded_routes = 5;
}
IPv4Settings ipv4_settings = 5;

message IPv6Settings {
repeated string addrs = 1;
repeated uint32 prefix_lengths = 2;

message IPv6Route {
string destination = 1;
uint32 prefix_length = 2;
// router is the address of the next-hop
string router = 3;
}
repeated IPv6Route included_routes = 3;
repeated IPv6Route excluded_routes = 4;
}
IPv6Settings ipv6_settings = 6;
}

// NetworkSettingsResponse is the response from the manager to the tunnel for a
// NetworkSettingsRequest
message NetworkSettingsResponse {
bool success = 1;
string error_message = 2;
bool success = 1;
string error_message = 2;
}

// StartRequest is a request from the manager to start the tunnel. The tunnel replies with a
// StartResponse.
message StartRequest {
int32 tunnel_file_descriptor = 1;
string coder_url = 2;
string api_token = 3;
int32 tunnel_file_descriptor = 1;
string coder_url = 2;
string api_token = 3;
// Additional HTTP headers added to all requests
message Header {
string name = 1;
string value = 2;
}
repeated Header headers = 4;
}

message StartResponse {
bool success = 1;
string error_message = 2;
bool success = 1;
string error_message = 2;
}

// StopRequest is a request from the manager to stop the tunnel. The tunnel replies with a
@@ -193,6 +199,6 @@ message StopRequest {}
// StopResponse is a response to stopping the tunnel. After sending this response, the tunnel closes
// its side of the bidirectional stream for writing.
message StopResponse {
bool success = 1;
string error_message = 2;
bool success = 1;
string error_message = 2;
}
11 changes: 0 additions & 11 deletions Coder Desktop/VPNXPC/VPNXPC.h

This file was deleted.

24 changes: 3 additions & 21 deletions Coder Desktop/project.yml
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@ name: "Coder Desktop"
options:
bundleIdPrefix: com.coder
deploymentTarget:
macOS: "14.6"
macOS: "14.0"
xcodeVersion: "1600"
minimumXcodeGenVersion: "2.42.0"

@@ -146,7 +146,7 @@ targets:
dependencies:
- target: CoderSDK
embed: true
- target: VPNXPC
- target: VPNLib
embed: true
- target: VPN
embed: without-signing # Embed without signing.
@@ -220,8 +220,6 @@ targets:
embed: true
- target: CoderSDK
embed: true
- target: VPNXPC
embed: true
- sdk: NetworkExtension.framework

VPNLib:
@@ -299,20 +297,4 @@ targets:
settings:
base:
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Coder Desktop.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Coder Desktop"
PRODUCT_BUNDLE_IDENTIFIER: com.coder.Coder-Desktop.CoderSDKTests

VPNXPC:
type: framework
Copy link
Member Author

Choose a reason for hiding this comment

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

Now that we import VPNLib into the app for the peer update protobuf type, we might as well just put the contents of this framework there.

platform: macOS
sources:
- path: VPNXPC
settings:
base:
INFOPLIST_KEY_NSHumanReadableCopyright: ""
PRODUCT_NAME: "$(TARGET_NAME:c99extidentifier)"
SWIFT_EMIT_LOC_STRINGS: YES
GENERATE_INFOPLIST_FILE: YES
DYLIB_COMPATIBILITY_VERSION: 1
DYLIB_CURRENT_VERSION: 1
DYLIB_INSTALL_NAME_BASE: "@rpath"
dependencies: []
PRODUCT_BUNDLE_IDENTIFIER: com.coder.Coder-Desktop.CoderSDKTests