Skip to content
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
2 changes: 1 addition & 1 deletion CoderSdk/Agent/AgentApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TResponse> SendRequestNoBodyAsync<TResponse>(HttpMethod method, string path,
Expand Down
10 changes: 6 additions & 4 deletions CoderSdk/Coder/CoderApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
3 changes: 2 additions & 1 deletion CoderSdk/JsonHttpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -36,6 +36,7 @@ public JsonHttpClient(Uri baseUri, IJsonTypeInfoResolver typeResolver)
};
_jsonOptions.Converters.Add(new JsonStringEnumConverter(new SnakeCaseNamingPolicy(), false));
_httpClient.BaseAddress = baseUri;
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent.Build(component));
}

public void RemoveHeader(string key)
Expand Down
81 changes: 81 additions & 0 deletions CoderSdk/UserAgent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using System.Reflection;
using System.Runtime.InteropServices;

namespace Coder.Desktop.CoderSdk;

/// <summary>
/// Identifies which Coder Desktop process is making a request.
/// </summary>
public enum CoderComponent
{
/// <summary>The tray application.</summary>
Desktop,

/// <summary>The privileged background service that manages the VPN tunnel.</summary>
Core,
}

/// <summary>
/// Builds the User-Agent header value sent by every Coder Desktop HTTP client.
/// </summary>
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";

/// <summary>
/// Builds a User-Agent for <paramref name="component" />, taking the version from the entry assembly.
/// </summary>
public static string Build(CoderComponent component)
{
return Build(component, Assembly.GetEntryAssembly());
}

/// <summary>
/// Builds a User-Agent for component, taking the version from versionSource. Prefer the single-argument overload outside of tests.
/// </summary>
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,
};
}
}
55 changes: 55 additions & 0 deletions Tests.CoderSdk/UserAgentTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using System.Reflection;
using System.Text.RegularExpressions;
using Coder.Desktop.CoderSdk;

namespace Coder.Desktop.Tests.CoderSdk;

[TestFixture]
public class UserAgentTest
{
// Every Coder client is expected to match this, so operators can allowlist the token set. Keep it in
// sync with the Coder CLI (cli/root.go) and the macOS and Linux Desktop clients.
private static readonly Regex Grammar = new(
@"^coder-(cli|desktop|desktop-core|vpn-daemon)/\d+\.\d+\.\d+ \((windows|darwin|linux|unknown)/(386|amd64|arm|arm64|unknown)(; .+)?\)$");
Comment thread
jeremyruppel marked this conversation as resolved.

[Test(Description = "Matches the shared User-Agent grammar")]
[TestCase(CoderComponent.Desktop)]
[TestCase(CoderComponent.Core)]
public void MatchesGrammar(CoderComponent component)
{
Assert.That(UserAgent.Build(component), Does.Match(Grammar));
}

[Test(Description = "Each component reports its own token")]
public void ComponentTokens()
{
Assert.That(UserAgent.Build(CoderComponent.Desktop), Does.StartWith("coder-desktop/"));
Assert.That(UserAgent.Build(CoderComponent.Core), Does.StartWith("coder-desktop-core/"));
}

[Test(Description = "Four-part assembly versions are trimmed to the three-part release version")]
public void TrimsAssemblyVersion()
{
var assembly = Assembly.GetExecutingAssembly();
var version = assembly.GetName().Version;
Assert.That(version, Is.Not.Null, "test assembly has no version");

var ua = UserAgent.Build(CoderComponent.Desktop, assembly);
Assert.That(ua, Does.Match(Grammar));
Assert.That(ua, Does.StartWith($"coder-desktop/{version!.Major}.{version.Minor}.{version.Build} ("));
}

[Test(Description = "An unversioned assembly still produces a well-formed User-Agent")]
public void UnknownVersion()
{
var ua = UserAgent.Build(CoderComponent.Desktop, null);
Assert.That(ua, Does.Match(Grammar));
Assert.That(ua, Does.StartWith("coder-desktop/0.0.0 ("));
}

[Test(Description = "Unrecognized components are rejected rather than silently mislabeled")]
public void UnknownComponent()
{
Assert.Throws<ArgumentOutOfRangeException>(() => UserAgent.Build((CoderComponent)(-1)));
}
}
40 changes: 40 additions & 0 deletions Tests.Vpn.Service/DownloaderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using Coder.Desktop.CoderSdk;
using Coder.Desktop.Vpn.Service;
using Microsoft.Extensions.Logging.Abstractions;

Expand Down Expand Up @@ -377,6 +378,45 @@ public async Task WithHeaders(CancellationToken ct)
await dlTask.Task;
}

[Test(Description = "Download identifies itself with a User-Agent")]
[CancelAfter(30_000)]
public async Task WithUserAgent(CancellationToken ct)
{
// Deployments behind a WAF reject requests with no User-Agent, which previously made the
// tunnel binary undownloadable. See coder-desktop-windows#176.
using var httpServer = new TestHttpServer(ctx =>
{
Assert.That(ctx.Request.UserAgent, Is.EqualTo(UserAgent.Build(CoderComponent.Core)));
ctx.Response.StatusCode = 200;
});
var url = new Uri(httpServer.BaseUrl + "/test");
var destPath = Path.Combine(_tempDir, "test");

var manager = new Downloader(NullLogger<Downloader>.Instance);
var req = new HttpRequestMessage(HttpMethod.Get, url);
var dlTask = await manager.StartDownloadAsync(req, destPath, NullDownloadValidator.Instance, ct);
await dlTask.Task;
}

[Test(Description = "A caller-supplied User-Agent overrides the default")]
[CancelAfter(30_000)]
public async Task WithUserAgentOverride(CancellationToken ct)
{
using var httpServer = new TestHttpServer(ctx =>
{
Assert.That(ctx.Request.UserAgent, Is.EqualTo("custom-agent/1.2.3"));
ctx.Response.StatusCode = 200;
});
var url = new Uri(httpServer.BaseUrl + "/test");
var destPath = Path.Combine(_tempDir, "test");

var manager = new Downloader(NullLogger<Downloader>.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;
}

[Test(Description = "Perform a download against an existing identical file")]
[CancelAfter(30_000)]
public async Task DownloadExisting(CancellationToken ct)
Expand Down
13 changes: 10 additions & 3 deletions Vpn.Service/Downloader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Runtime.ExceptionServices;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Coder.Desktop.CoderSdk;
using Coder.Desktop.Vpn.Utilities;
using Microsoft.Extensions.Logging;
using Microsoft.Security.Extensions;
Expand Down Expand Up @@ -348,10 +349,16 @@ 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
private static readonly HttpClient HttpClient = new Func<HttpClient>(() =>
{
AutomaticDecompression = DecompressionMethods.All,
});
var client = new HttpClient(new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All,
});
client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent.Build(CoderComponent.Core));
return client;
})();
Comment thread
jeremyruppel marked this conversation as resolved.

private readonly string _destinationDirectory;

private readonly ILogger _logger;
Expand Down
3 changes: 2 additions & 1 deletion Vpn.Service/Manager.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -440,7 +441,7 @@ private static string SystemArchitecture()
private async ValueTask<ServerVersion> 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);
Expand Down
Loading