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
4 changes: 3 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,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)
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,
};
}
}
2 changes: 2 additions & 0 deletions Packaging.Linux/PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down
2 changes: 2 additions & 0 deletions Packaging.Linux/build-release-packages.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
65 changes: 65 additions & 0 deletions Tests.CoderSdk/UserAgentTest.cs
Original file line number Diff line number Diff line change
@@ -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(
@"^(?<token>coder-desktop|coder-desktop-core)/(?<version>[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<ArgumentOutOfRangeException>(() =>
UserAgent.Build((CoderComponent)(-1), null));
Assert.That(ex!.ParamName, Is.EqualTo("component"));
}
}
48 changes: 48 additions & 0 deletions Tests.Vpn.Service/DownloaderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Downloader>.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<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;

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)
Expand Down
16 changes: 12 additions & 4 deletions Vpn.Service/Downloader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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; }
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 @@ -393,7 +394,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