Skip to content

feat: add enrichment of StartRequest with OS, device ID, version #123

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 4 commits into from
Mar 27, 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
3 changes: 3 additions & 0 deletions Coder-Desktop/VPN/Manager.swift
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@ import VPNLib
actor Manager {
let ptp: PacketTunnelProvider
let cfg: ManagerConfig
let telemetryEnricher: TelemetryEnricher

let tunnelHandle: TunnelHandle
let speaker: Speaker<Vpn_ManagerMessage, Vpn_TunnelMessage>
@@ -19,6 +20,7 @@ actor Manager {
init(with: PacketTunnelProvider, cfg: ManagerConfig) async throws(ManagerError) {
ptp = with
self.cfg = cfg
telemetryEnricher = TelemetryEnricher()
#if arch(arm64)
let dylibPath = cfg.serverUrl.appending(path: "bin/coder-vpn-darwin-arm64.dylib")
#elseif arch(x86_64)
@@ -176,6 +178,7 @@ actor Manager {
req.value = header.value
}
}
req = telemetryEnricher.enrich(req)
}
})
} catch {
7 changes: 5 additions & 2 deletions Coder-Desktop/VPNLib/Speaker.swift
Original file line number Diff line number Diff line change
@@ -88,8 +88,11 @@ public actor Speaker<SendMsg: RPCMessage & Message, RecvMsg: RPCMessage & Messag

/// Does the VPN Protocol handshake and validates the result
public func handshake() async throws(HandshakeError) {
let hndsh = Handshaker(writeFD: writeFD, dispatch: dispatch, queue: queue, role: role)
// ignore the version for now because we know it can only be 1.0
let hndsh = Handshaker(writeFD: writeFD, dispatch: dispatch, queue: queue, role: role,
versions: [ProtoVersion(1, 1)])
// ignore the version for now because we know it can only be 1.0 or 1.1.
// 1.1 adds support for telemetry to StartRequest, but since setting these
// fields won't adversely affect a 1.0 speaker, we set them regardless.
try _ = await hndsh.handshake()
}

31 changes: 31 additions & 0 deletions Coder-Desktop/VPNLib/TelemetryEnricher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Foundation

public struct TelemetryEnricher {
private let deviceID: String
private let version: String?

public init() {
version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String

let userDefaults = UserDefaults.standard
let key = "deviceID"

if let existingID = userDefaults.string(forKey: key) {
deviceID = existingID
} else {
let newID = UUID().uuidString
userDefaults.set(newID, forKey: key)
deviceID = newID
}
}

public func enrich(_ original: Vpn_StartRequest) -> Vpn_StartRequest {
var req = original
req.deviceOs = "macOS"
req.deviceID = deviceID
if let version {
req.coderDesktopVersion = version
}
return req
}
}
492 changes: 492 additions & 0 deletions Coder-Desktop/VPNLib/vpn.pb.swift

Large diffs are not rendered by default.

49 changes: 49 additions & 0 deletions Coder-Desktop/VPNLib/vpn.proto
Original file line number Diff line number Diff line change
@@ -44,6 +44,26 @@ message TunnelMessage {
}
}

// ClientMessage is a message from the client (to the service). Windows only.
message ClientMessage {
RPC rpc = 1;
oneof msg {
StartRequest start = 2;
StopRequest stop = 3;
StatusRequest status = 4;
}
}

// ServiceMessage is a message from the service (to the client). Windows only.
message ServiceMessage {
RPC rpc = 1;
oneof msg {
StartResponse start = 2;
StopResponse stop = 3;
Status status = 4; // either in reply to a StatusRequest or broadcasted
}
}

// 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 {
@@ -185,6 +205,12 @@ message StartRequest {
string value = 2;
}
repeated Header headers = 4;
// Device ID from Coder Desktop
string device_id = 5;
// Device OS from Coder Desktop
string device_os = 6;
// Coder Desktop version
string coder_desktop_version = 7;
}

message StartResponse {
@@ -202,3 +228,26 @@ message StopResponse {
bool success = 1;
string error_message = 2;
}

// StatusRequest is a request to get the status of the tunnel. The manager
// replies with a Status.
message StatusRequest {}

// Status is sent in response to a StatusRequest or broadcasted to all clients
// when the status changes.
message Status {
enum Lifecycle {
UNKNOWN = 0;
STARTING = 1;
STARTED = 2;
STOPPING = 3;
STOPPED = 4;
}
Lifecycle lifecycle = 1;
string error_message = 2;

// This will be a FULL update with all workspaces and agents, so clients
// should replace their current peer state. Only the Upserted fields will
// be populated.
PeerUpdate peer_update = 3;
}
5 changes: 3 additions & 2 deletions Coder-Desktop/VPNLibTests/SpeakerTests.swift
Original file line number Diff line number Diff line change
@@ -29,14 +29,15 @@ struct SpeakerTests: Sendable {
handshaker = Handshaker(
writeFD: pipeMT.fileHandleForWriting,
dispatch: dispatch, queue: queue,
role: .manager
role: .manager,
versions: [ProtoVersion(1, 1)]
)
}

@Test func handshake() async throws {
async let v = handshaker.handshake()
try await uut.handshake()
#expect(try await v == ProtoVersion(1, 0))
#expect(try await v == ProtoVersion(1, 1))
}

@Test func handleSingleMessage() async throws {
25 changes: 25 additions & 0 deletions Coder-Desktop/VPNLibTests/TelemetryEnricherTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Testing
@testable import VPNLib

@Suite(.timeLimit(.minutes(1)))
struct TelemetryEnricherTests {
@Test func testEnrichStartRequest() throws {
let enricher0 = TelemetryEnricher()
let original = Vpn_StartRequest.with { req in
req.coderURL = "https://example.com"
req.tunnelFileDescriptor = 123
}
var enriched = enricher0.enrich(original)
#expect(enriched.coderURL == "https://example.com")
#expect(enriched.tunnelFileDescriptor == 123)
#expect(enriched.deviceOs == "macOS")
#expect(try enriched.coderDesktopVersion.contains(Regex(#"^\d+\.\d+\.\d+$"#)))
let deviceID = enriched.deviceID
#expect(!deviceID.isEmpty)

// check we get the same deviceID from a new enricher
let enricher1 = TelemetryEnricher()
enriched = enricher1.enrich(original)
#expect(enriched.deviceID == deviceID)
}
}