diff --git a/.editorconfig b/.editorconfig index d0323ad..cd2fd68 100644 --- a/.editorconfig +++ b/.editorconfig @@ -76,6 +76,10 @@ resharper_web_config_module_not_resolved_highlighting = warning resharper_web_config_type_not_resolved_highlighting = warning resharper_web_config_wrong_module_highlighting = warning -[{*.json,*.jsonc,*.yml,*.yaml,*.proto}] +[{*.json,*.jsonc,*.yml,*.yaml}] indent_style = space indent_size = 2 + +[{*.proto}] +indent_style = tab +indent_size = 1 \ No newline at end of file diff --git a/Tests.Vpn.Service/TelemetryEnricherTest.cs b/Tests.Vpn.Service/TelemetryEnricherTest.cs new file mode 100644 index 0000000..144cd20 --- /dev/null +++ b/Tests.Vpn.Service/TelemetryEnricherTest.cs @@ -0,0 +1,34 @@ +using Coder.Desktop.Vpn.Proto; +using Coder.Desktop.Vpn.Service; + +namespace Coder.Desktop.Tests.Vpn.Service; + +[TestFixture] +public class TelemetryEnricherTest +{ + [Test] + public void EnrichStartRequest() + { + var req = new StartRequest + { + CoderUrl = "https://coder.example.com", + }; + var enricher = new TelemetryEnricher(); + req = enricher.EnrichStartRequest(req); + + // quick sanity check that non-telemetry fields aren't lost or overwritten + Assert.That(req.CoderUrl, Is.EqualTo("https://coder.example.com")); + + Assert.That(req.DeviceOs, Is.EqualTo("Windows")); + // seems that test assemblies always set 1.0.0.0 + Assert.That(req.CoderDesktopVersion, Is.EqualTo("1.0.0.0")); + Assert.That(req.DeviceId, Is.Not.Empty); + var deviceId = req.DeviceId; + + // deviceId is different on different machines, but we can test that + // each instance of the TelemetryEnricher produces the same value. + enricher = new TelemetryEnricher(); + req = enricher.EnrichStartRequest(new StartRequest()); + Assert.That(req.DeviceId, Is.EqualTo(deviceId)); + } +} diff --git a/Vpn.Proto/RpcVersion.cs b/Vpn.Proto/RpcVersion.cs index 33e0c16..641a10d 100644 --- a/Vpn.Proto/RpcVersion.cs +++ b/Vpn.Proto/RpcVersion.cs @@ -5,7 +5,7 @@ namespace Coder.Desktop.Vpn.Proto; /// public class RpcVersion { - public static readonly RpcVersion Current = new(1, 0); + public static readonly RpcVersion Current = new(1, 1); public ulong Major { get; } public ulong Minor { get; } diff --git a/Vpn.Proto/vpn.proto b/Vpn.Proto/vpn.proto index 8a4800d..2561a4b 100644 --- a/Vpn.Proto/vpn.proto +++ b/Vpn.Proto/vpn.proto @@ -17,75 +17,75 @@ 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; + } } // 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; + 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 - } + 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 { - 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. @@ -95,132 +95,138 @@ 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 - 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; + 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; - // Additional HTTP headers added to all requests - message Header { - string name = 1; - string value = 2; - } - repeated Header headers = 4; + 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; + // 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 { - bool success = 1; - string error_message = 2; + bool success = 1; + string error_message = 2; } -// StopRequest is a request to stop the tunnel. The tunnel replies with a +// StopRequest is a request from the manager to stop the tunnel. The tunnel replies with a // StopResponse. 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; } // StatusRequest is a request to get the status of the tunnel. The manager @@ -230,18 +236,18 @@ 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; + 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; } diff --git a/Vpn.Service/Manager.cs b/Vpn.Service/Manager.cs index 3345e98..1eca8bf 100644 --- a/Vpn.Service/Manager.cs +++ b/Vpn.Service/Manager.cs @@ -31,6 +31,7 @@ public class Manager : IManager private readonly ILogger _logger; private readonly ITunnelSupervisor _tunnelSupervisor; private readonly IManagerRpc _managerRpc; + private readonly ITelemetryEnricher _telemetryEnricher; private volatile TunnelStatus _status = TunnelStatus.Stopped; @@ -46,7 +47,7 @@ public class Manager : IManager // ReSharper disable once ConvertToPrimaryConstructor public Manager(IOptions config, ILogger logger, IDownloader downloader, - ITunnelSupervisor tunnelSupervisor, IManagerRpc managerRpc) + ITunnelSupervisor tunnelSupervisor, IManagerRpc managerRpc, ITelemetryEnricher telemetryEnricher) { _config = config.Value; _logger = logger; @@ -54,6 +55,7 @@ public Manager(IOptions config, ILogger logger, IDownloa _tunnelSupervisor = tunnelSupervisor; _managerRpc = managerRpc; _managerRpc.OnReceive += HandleClientRpcMessage; + _telemetryEnricher = telemetryEnricher; } public void Dispose() @@ -159,7 +161,7 @@ await _tunnelSupervisor.StartAsync(_config.TunnelBinaryPath, HandleTunnelRpcMess var reply = await _tunnelSupervisor.SendRequestAwaitReply(new ManagerMessage { - Start = message.Start, + Start = _telemetryEnricher.EnrichStartRequest(message.Start), }, ct); if (reply.MsgCase != TunnelMessage.MsgOneofCase.Start) throw new InvalidOperationException("Tunnel did not reply with a Start response"); diff --git a/Vpn.Service/Program.cs b/Vpn.Service/Program.cs index e22daa2..69b6ea8 100644 --- a/Vpn.Service/Program.cs +++ b/Vpn.Service/Program.cs @@ -85,6 +85,7 @@ private static async Task BuildAndRun(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); // Services if (!Environment.UserInteractive) diff --git a/Vpn.Service/TelemetryEnricher.cs b/Vpn.Service/TelemetryEnricher.cs new file mode 100644 index 0000000..2169334 --- /dev/null +++ b/Vpn.Service/TelemetryEnricher.cs @@ -0,0 +1,51 @@ +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using Coder.Desktop.Vpn.Proto; +using Microsoft.Win32; + +namespace Coder.Desktop.Vpn.Service; + +// +// ITelemetryEnricher contains methods for enriching messages with telemetry +// information +// +public interface ITelemetryEnricher +{ + public StartRequest EnrichStartRequest(StartRequest original); +} + +public class TelemetryEnricher : ITelemetryEnricher +{ + private readonly string? _version; + private readonly string? _deviceID; + + public TelemetryEnricher() + { + var assembly = Assembly.GetExecutingAssembly(); + _version = assembly.GetName().Version?.ToString(); + + using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\SQMClient"); + if (key != null) + { + // this is the "Device ID" shown in settings. I don't think it's personally + // identifiable, but let's hash it just to be sure. + var deviceID = key.GetValue("MachineId") as string; + if (!string.IsNullOrEmpty(deviceID)) + { + var idBytes = Encoding.UTF8.GetBytes(deviceID); + var hash = SHA256.HashData(idBytes); + _deviceID = Convert.ToBase64String(hash); + } + } + } + + public StartRequest EnrichStartRequest(StartRequest original) + { + var req = original.Clone(); + req.DeviceOs = "Windows"; + if (_version != null) req.CoderDesktopVersion = _version; + if (_deviceID != null) req.DeviceId = _deviceID; + return req; + } +} diff --git a/scripts/Publish.ps1 b/scripts/Publish.ps1 index 5f7a25e..dccb39f 100644 --- a/scripts/Publish.ps1 +++ b/scripts/Publish.ps1 @@ -116,14 +116,14 @@ New-Item -ItemType Directory -Path $buildPath -Force & dotnet.exe restore if ($LASTEXITCODE -ne 0) { throw "Failed to dotnet restore" } $servicePublishDir = Join-Path $buildPath "service" -& dotnet.exe publish .\Vpn.Service\Vpn.Service.csproj -c Release -a $arch -o $servicePublishDir +& dotnet.exe publish .\Vpn.Service\Vpn.Service.csproj -c Release -a $arch -o $servicePublishDir /p:Version=$version if ($LASTEXITCODE -ne 0) { throw "Failed to build Vpn.Service" } # App needs to be built with msbuild $appPublishDir = Join-Path $buildPath "app" $msbuildBinary = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe if ($LASTEXITCODE -ne 0) { throw "Failed to find MSBuild" } if (-not (Test-Path $msbuildBinary)) { throw "Failed to find MSBuild at $msbuildBinary" } -& $msbuildBinary .\App\App.csproj /p:Configuration=Release /p:Platform=$arch /p:OutputPath=$appPublishDir +& $msbuildBinary .\App\App.csproj /p:Configuration=Release /p:Platform=$arch /p:OutputPath=$appPublishDir /p:Version=$version if ($LASTEXITCODE -ne 0) { throw "Failed to build App" } # Find any files in the publish directory recursively that match any of our