diff --git a/CoderSdk/Agent/AgentApiClient.cs b/CoderSdk/Agent/AgentApiClient.cs index 27eaea3..bfd171b 100644 --- a/CoderSdk/Agent/AgentApiClient.cs +++ b/CoderSdk/Agent/AgentApiClient.cs @@ -44,7 +44,7 @@ public AgentApiClient(Uri baseUrl) { if (baseUrl.PathAndQuery != "/") throw new ArgumentException($"Base URL '{baseUrl}' must not contain a path", nameof(baseUrl)); - _httpClient = new JsonHttpClient(baseUrl, AgentApiJsonContext.Default); + _httpClient = new JsonHttpClient(baseUrl, AgentApiJsonContext.Default, CoderComponent.Desktop); } private async Task SendRequestNoBodyAsync(HttpMethod method, string path, diff --git a/CoderSdk/Coder/CoderApiClient.cs b/CoderSdk/Coder/CoderApiClient.cs index a24f364..2257c54 100644 --- a/CoderSdk/Coder/CoderApiClient.cs +++ b/CoderSdk/Coder/CoderApiClient.cs @@ -67,18 +67,20 @@ public partial class CoderApiClient : ICoderApiClient private readonly JsonHttpClient _httpClient; - public CoderApiClient(string baseUrl) : this(new Uri(baseUrl, UriKind.Absolute)) + public CoderApiClient(string baseUrl, CoderComponent component = CoderComponent.Desktop) + : this(new Uri(baseUrl, UriKind.Absolute), component) { } - public CoderApiClient(Uri baseUrl) + public CoderApiClient(Uri baseUrl, CoderComponent component = CoderComponent.Desktop) { if (baseUrl.PathAndQuery != "/") throw new ArgumentException($"Base URL '{baseUrl}' must not contain a path", nameof(baseUrl)); - _httpClient = new JsonHttpClient(baseUrl, CoderApiJsonContext.Default); + _httpClient = new JsonHttpClient(baseUrl, CoderApiJsonContext.Default, component); } - public CoderApiClient(string baseUrl, string token) : this(baseUrl) + public CoderApiClient(string baseUrl, string token, CoderComponent component = CoderComponent.Desktop) + : this(baseUrl, component) { SetSessionToken(token); } diff --git a/CoderSdk/JsonHttpClient.cs b/CoderSdk/JsonHttpClient.cs index 362391e..1012cff 100644 --- a/CoderSdk/JsonHttpClient.cs +++ b/CoderSdk/JsonHttpClient.cs @@ -25,7 +25,7 @@ internal class JsonHttpClient // TODO: allow users to add headers private readonly HttpClient _httpClient = new(); - public JsonHttpClient(Uri baseUri, IJsonTypeInfoResolver typeResolver) + public JsonHttpClient(Uri baseUri, IJsonTypeInfoResolver typeResolver, CoderComponent component) { _jsonOptions = new JsonSerializerOptions { @@ -36,6 +36,8 @@ public JsonHttpClient(Uri baseUri, IJsonTypeInfoResolver typeResolver) }; _jsonOptions.Converters.Add(new JsonStringEnumConverter(new SnakeCaseNamingPolicy(), false)); _httpClient.BaseAddress = baseUri; + // A default header is skipped when the request already sets it + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent.Build(component)); } public void RemoveHeader(string key) diff --git a/CoderSdk/UserAgent.cs b/CoderSdk/UserAgent.cs new file mode 100644 index 0000000..a7d8a1e --- /dev/null +++ b/CoderSdk/UserAgent.cs @@ -0,0 +1,81 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +namespace Coder.Desktop.CoderSdk; + +/// +/// Identifies which Coder Desktop process is making a request. +/// +public enum CoderComponent +{ + /// The tray application. + Desktop, + + /// The privileged background service that manages the VPN tunnel. + Core, +} + +/// +/// Builds the User-Agent header value sent by every Coder Desktop HTTP client. +/// +public static class UserAgent +{ + private const string DesktopToken = "coder-desktop"; + private const string CoreToken = "coder-desktop-core"; + + private const string UnknownVersion = "0.0.0"; + private const string UnknownPlatform = "unknown"; + + /// + /// Builds a User-Agent for , taking the version from the entry assembly. + /// + public static string Build(CoderComponent component) + { + return Build(component, Assembly.GetEntryAssembly()); + } + + /// + /// Builds a User-Agent for component, taking the version from versionSource. Prefer the single-argument overload outside of tests. + /// + public static string Build(CoderComponent component, Assembly? versionSource) + { + return $"{TokenOf(component)}/{VersionOf(versionSource)} ({Goos()}/{Goarch()})"; + } + + private static string TokenOf(CoderComponent component) + { + return component switch + { + CoderComponent.Desktop => DesktopToken, + CoderComponent.Core => CoreToken, + _ => throw new ArgumentOutOfRangeException(nameof(component), component, null), + }; + } + + private static string VersionOf(Assembly? assembly) + { + // Assembly versions are four-part (0.8.4.0); the User-Agent reports the three-part release. + var version = assembly?.GetName().Version; + return version is null ? UnknownVersion : $"{version.Major}.{version.Minor}.{version.Build}"; + } + + // Platform names deliberately match Go's GOOS/GOARCH Desktop clients, the CLI and the vpn-daemon + private static string Goos() + { + if (OperatingSystem.IsWindows()) return "windows"; + if (OperatingSystem.IsMacOS()) return "darwin"; + if (OperatingSystem.IsLinux()) return "linux"; + return UnknownPlatform; + } + + private static string Goarch() + { + return RuntimeInformation.ProcessArchitecture switch + { + Architecture.X64 => "amd64", + Architecture.Arm => "arm", + Architecture.Arm64 => "arm64", + _ => UnknownPlatform, + }; + } +} diff --git a/Packaging.Linux/PKGBUILD b/Packaging.Linux/PKGBUILD index 80fdb30..bb0a614 100644 --- a/Packaging.Linux/PKGBUILD +++ b/Packaging.Linux/PKGBUILD @@ -26,11 +26,13 @@ build() { dotnet publish "${repo_root}/App.Avalonia" \ -r "${rid}" \ -c Release \ + /p:Version="${pkgver}" \ -o "${srcdir}/build/app" dotnet publish "${repo_root}/Vpn.Service" \ -r "${rid}" \ -c Release \ + /p:Version="${pkgver}" \ -o "${srcdir}/build/service" } diff --git a/Packaging.Linux/build-release-packages.sh b/Packaging.Linux/build-release-packages.sh index 4a16e05..97e9bb7 100755 --- a/Packaging.Linux/build-release-packages.sh +++ b/Packaging.Linux/build-release-packages.sh @@ -61,12 +61,14 @@ echo "[release-packaging] Publishing App.Avalonia (${RID})" dotnet publish "$ROOT_DIR/App.Avalonia" \ -r "$RID" \ -c Release \ + /p:Version="$VERSION" \ -o "$STAGE_DIR/usr/lib/coder-desktop/app" echo "[release-packaging] Publishing Vpn.Service (${RID})" dotnet publish "$ROOT_DIR/Vpn.Service" \ -r "$RID" \ -c Release \ + /p:Version="$VERSION" \ -o "$STAGE_DIR/usr/lib/coder-desktop/service" mkdir -p "$STAGE_DIR/usr/bin" diff --git a/Tests.CoderSdk/UserAgentTest.cs b/Tests.CoderSdk/UserAgentTest.cs new file mode 100644 index 0000000..8a77435 --- /dev/null +++ b/Tests.CoderSdk/UserAgentTest.cs @@ -0,0 +1,65 @@ +using System.Reflection; +using System.Text.RegularExpressions; +using Coder.Desktop.CoderSdk; + +namespace Coder.Desktop.Tests.CoderSdk; + +[TestFixture] +public class UserAgentTest +{ + // The grammar every Coder client is expected to produce. Asserting against the pattern rather + // than recomputing the platform names keeps the test honest about the mapping in UserAgent. + private static readonly Regex Grammar = new( + @"^(?coder-desktop|coder-desktop-core)/(?[0-9]+\.[0-9]+\.[0-9]+) \((windows|darwin|linux)/(386|amd64|arm|arm64)\)$", + RegexOptions.Compiled); + + [Test(Description = "Desktop User-Agent matches the shared grammar")] + public void DesktopMatchesGrammar() + { + var match = Grammar.Match(UserAgent.Build(CoderComponent.Desktop)); + Assert.That(match.Success, Is.True); + Assert.That(match.Groups["token"].Value, Is.EqualTo("coder-desktop")); + } + + [Test(Description = "Core User-Agent matches the shared grammar")] + public void CoreMatchesGrammar() + { + var match = Grammar.Match(UserAgent.Build(CoderComponent.Core)); + Assert.That(match.Success, Is.True); + Assert.That(match.Groups["token"].Value, Is.EqualTo("coder-desktop-core")); + } + + [Test(Description = "Four-part assembly version is trimmed to three parts")] + public void TrimsVersionToThreeParts() + { + var assembly = Assembly.GetExecutingAssembly(); + var version = assembly.GetName().Version; + Assert.That(version, Is.Not.Null); + // Precondition: assembly versions are four-part, which is what makes trimming observable. + Assert.That(version!.ToString().Split('.'), Has.Length.EqualTo(4)); + + var userAgent = UserAgent.Build(CoderComponent.Desktop, assembly); + + var match = Grammar.Match(userAgent); + Assert.That(match.Success, Is.True); + Assert.That(match.Groups["version"].Value, + Is.EqualTo($"{version.Major}.{version.Minor}.{version.Build}")); + Assert.That(userAgent, Does.Not.Contain(version.ToString())); + } + + [Test(Description = "A null assembly reports an unknown version")] + public void NullAssemblyReportsUnknownVersion() + { + var userAgent = UserAgent.Build(CoderComponent.Core, null); + Assert.That(Grammar.IsMatch(userAgent), Is.True); + Assert.That(userAgent, Does.StartWith("coder-desktop-core/0.0.0 ")); + } + + [Test(Description = "An unknown component is rejected")] + public void UnknownComponentThrows() + { + var ex = Assert.Throws(() => + UserAgent.Build((CoderComponent)(-1), null)); + Assert.That(ex!.ParamName, Is.EqualTo("component")); + } +} diff --git a/Tests.Vpn.Service/DownloaderTest.cs b/Tests.Vpn.Service/DownloaderTest.cs index de27806..d8fb3b5 100644 --- a/Tests.Vpn.Service/DownloaderTest.cs +++ b/Tests.Vpn.Service/DownloaderTest.cs @@ -363,6 +363,54 @@ public async Task DownloadWithMismatchedContentLength(CancellationToken ct) Assert.That(ex.Message, Is.EqualTo("Downloaded file size does not match expected response content length: Expected=5, BytesWritten=4")); } + [Test(Description = "Download sends the Core User-Agent")] + [CancelAfter(30_000)] + public async Task SendsUserAgent(CancellationToken ct) + { + // TestHttpServer turns a handler exception into a 500, so capture the header and assert + // on it after the download completes rather than inside the handler. + string? observedUserAgent = null; + using var httpServer = new TestHttpServer(ctx => + { + observedUserAgent = ctx.Request.UserAgent; + ctx.Response.StatusCode = 200; + }); + var url = new Uri(httpServer.BaseUrl + "/test"); + var destPath = Path.Combine(_tempDir, "test"); + + var manager = new Downloader(NullLogger.Instance); + var req = new HttpRequestMessage(HttpMethod.Get, url); + var dlTask = await manager.StartDownloadAsync(req, destPath, NullDownloadValidator.Instance, ct); + await dlTask.Task; + + Assert.That(observedUserAgent, Is.Not.Null); + Assert.That(observedUserAgent, Does.StartWith("coder-desktop-core/")); + Assert.That(observedUserAgent, Does.Match( + @"^coder-desktop-core/[0-9]+\.[0-9]+\.[0-9]+ \((windows|darwin|linux)/(386|amd64|arm|arm64)\)$")); + } + + [Test(Description = "A caller-supplied User-Agent overrides the default")] + [CancelAfter(30_000)] + public async Task CallerUserAgentWins(CancellationToken ct) + { + string? observedUserAgent = null; + using var httpServer = new TestHttpServer(ctx => + { + observedUserAgent = ctx.Request.UserAgent; + ctx.Response.StatusCode = 200; + }); + var url = new Uri(httpServer.BaseUrl + "/test"); + var destPath = Path.Combine(_tempDir, "test"); + + var manager = new Downloader(NullLogger.Instance); + var req = new HttpRequestMessage(HttpMethod.Get, url); + req.Headers.UserAgent.ParseAdd("custom-agent/1.2.3"); + var dlTask = await manager.StartDownloadAsync(req, destPath, NullDownloadValidator.Instance, ct); + await dlTask.Task; + + Assert.That(observedUserAgent, Is.EqualTo("custom-agent/1.2.3")); + } + [Test(Description = "Download with custom headers")] [CancelAfter(30_000)] public async Task WithHeaders(CancellationToken ct) diff --git a/Vpn.Service/Downloader.cs b/Vpn.Service/Downloader.cs index 5727e71..7d4d064 100644 --- a/Vpn.Service/Downloader.cs +++ b/Vpn.Service/Downloader.cs @@ -10,6 +10,7 @@ #if WINDOWS using System.Security.Cryptography.X509Certificates; #endif +using Coder.Desktop.CoderSdk; using Coder.Desktop.Vpn.Utilities; using Microsoft.Extensions.Logging; #if WINDOWS @@ -356,10 +357,7 @@ public class DownloadTask private const int BufferSize = 64 * 1024; private const string XOriginalContentLengthHeader = "X-Original-Content-Length"; // overrides Content-Length if available - private static readonly HttpClient HttpClient = new(new HttpClientHandler - { - AutomaticDecompression = DecompressionMethods.All, - }); + private static readonly HttpClient HttpClient = NewHttpClient(); private readonly string _destinationDirectory; private readonly ILogger _logger; @@ -371,6 +369,16 @@ public class DownloadTask public readonly HttpRequestMessage Request; + private static HttpClient NewHttpClient() + { + var client = new HttpClient(new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.All, + }); + client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent.Build(CoderComponent.Core)); + return client; + } + public Task Task { get; private set; } = null!; // Set in EnsureStartedAsync public bool DownloadStarted { get; private set; } // Whether we've received headers yet and started the actual download public ulong BytesWritten { get; private set; } diff --git a/Vpn.Service/Manager.cs b/Vpn.Service/Manager.cs index 54eedfb..4f7a5e5 100644 --- a/Vpn.Service/Manager.cs +++ b/Vpn.Service/Manager.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; +using Coder.Desktop.CoderSdk; using Coder.Desktop.CoderSdk.Coder; using Coder.Desktop.Vpn.Proto; using Coder.Desktop.Vpn.Utilities; @@ -393,7 +394,7 @@ private static string SystemArchitecture() private async ValueTask CheckServerVersionAndCredentials(string baseUrl, string apiToken, CancellationToken ct = default) { - var client = new CoderApiClient(baseUrl, apiToken); + var client = new CoderApiClient(baseUrl, apiToken, CoderComponent.Core); var buildInfo = await client.GetBuildInfo(ct); _logger.LogInformation("Fetched server version '{ServerVersion}'", buildInfo.Version);