Skip to content

Commit 0fb9567

Browse files
committed
chore: add Vpn.Service app for Manager
- Implements a basic .NET hosted service manager architecture with Microsoft.Extensions - Adds a Manager and ManagerService for handling Manager lifecycle - Adds a ManagerRpcService for managing the RPC server and passing requests to the Manager singleton - Adds a Downloader for handling singleflight for downloading files - Implements downloading with progress reporting, ETag validation, and Authenticode validation
1 parent 559b111 commit 0fb9567

23 files changed

+1336
-78
lines changed

Coder.Desktop.sln

+19-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coder.Desktop.Vpn", "Vpn\Vp
55
EndProject
66
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coder.Desktop.Vpn.Proto", "Vpn.Proto\Vpn.Proto.csproj", "{318E78BB-E6AD-410F-8F3F-B680F6880293}"
77
EndProject
8-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coder.Desktop.Tests", "Tests\Tests.csproj", "{D247B2E7-38A0-4A69-A710-7E8FAA7B807E}"
8+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Vpn", "Tests.Vpn\Tests.Vpn.csproj", "{D247B2E7-38A0-4A69-A710-7E8FAA7B807E}"
9+
EndProject
10+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coder.Desktop.Vpn.Service", "Vpn.Service\Vpn.Service.csproj", "{51B91794-0A2A-4F84-9935-8E17DD2AB260}"
11+
EndProject
12+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Vpn.Proto", "Tests.Vpn.Proto\Tests.Vpn.Proto.csproj", "{AA3EEFF4-414B-4A83-8ACF-188C3C61CCE1}"
13+
EndProject
14+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Vpn.Service", "Tests.Vpn.Service\Tests.Vpn.Service.csproj", "{D32E5FE1-C251-4A08-8EBE-B8D4F18A36F1}"
915
EndProject
1016
Global
1117
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -25,5 +31,17 @@ Global
2531
{D247B2E7-38A0-4A69-A710-7E8FAA7B807E}.Debug|Any CPU.Build.0 = Debug|Any CPU
2632
{D247B2E7-38A0-4A69-A710-7E8FAA7B807E}.Release|Any CPU.ActiveCfg = Release|Any CPU
2733
{D247B2E7-38A0-4A69-A710-7E8FAA7B807E}.Release|Any CPU.Build.0 = Release|Any CPU
34+
{51B91794-0A2A-4F84-9935-8E17DD2AB260}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
35+
{51B91794-0A2A-4F84-9935-8E17DD2AB260}.Debug|Any CPU.Build.0 = Debug|Any CPU
36+
{51B91794-0A2A-4F84-9935-8E17DD2AB260}.Release|Any CPU.ActiveCfg = Release|Any CPU
37+
{51B91794-0A2A-4F84-9935-8E17DD2AB260}.Release|Any CPU.Build.0 = Release|Any CPU
38+
{AA3EEFF4-414B-4A83-8ACF-188C3C61CCE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
39+
{AA3EEFF4-414B-4A83-8ACF-188C3C61CCE1}.Debug|Any CPU.Build.0 = Debug|Any CPU
40+
{AA3EEFF4-414B-4A83-8ACF-188C3C61CCE1}.Release|Any CPU.ActiveCfg = Release|Any CPU
41+
{AA3EEFF4-414B-4A83-8ACF-188C3C61CCE1}.Release|Any CPU.Build.0 = Release|Any CPU
42+
{D32E5FE1-C251-4A08-8EBE-B8D4F18A36F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
43+
{D32E5FE1-C251-4A08-8EBE-B8D4F18A36F1}.Debug|Any CPU.Build.0 = Debug|Any CPU
44+
{D32E5FE1-C251-4A08-8EBE-B8D4F18A36F1}.Release|Any CPU.ActiveCfg = Release|Any CPU
45+
{D32E5FE1-C251-4A08-8EBE-B8D4F18A36F1}.Release|Any CPU.Build.0 = Release|Any CPU
2846
EndGlobalSection
2947
EndGlobal

Coder.Desktop.sln.DotSettings

+1
Original file line numberDiff line numberDiff line change
@@ -253,4 +253,5 @@
253253
</Patterns>
254254
</s:String>
255255
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002EMemberReordering_002EMigrations_002ECSharpFileLayoutPatternRemoveIsAttributeUpgrade/@EntryIndexedValue">True</s:Boolean>
256+
<s:Boolean x:Key="/Default/UserDictionary/Words/=codervpn/@EntryIndexedValue">True</s:Boolean>
256257
<s:Boolean x:Key="/Default/UserDictionary/Words/=serdes/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<RootNamespace>Coder.Desktop.Tests.Vpn.Proto</RootNamespace>
5+
<TargetFramework>net8.0</TargetFramework>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
9+
<IsPackable>false</IsPackable>
10+
<IsTestProject>true</IsTestProject>
11+
</PropertyGroup>
12+
13+
<ItemGroup>
14+
<PackageReference Include="coverlet.collector" Version="6.0.2">
15+
<PrivateAssets>all</PrivateAssets>
16+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
17+
</PackageReference>
18+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
19+
<PackageReference Include="NUnit" Version="4.2.2"/>
20+
<PackageReference Include="NUnit.Analyzers" Version="4.4.0">
21+
<PrivateAssets>all</PrivateAssets>
22+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
23+
</PackageReference>
24+
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0"/>
25+
</ItemGroup>
26+
27+
<ItemGroup>
28+
<Using Include="NUnit.Framework"/>
29+
</ItemGroup>
30+
31+
<ItemGroup>
32+
<ProjectReference Include="..\Vpn.Proto\Vpn.Proto.csproj"/>
33+
</ItemGroup>
34+
35+
</Project>

Tests.Vpn.Service/DownloaderTest.cs

+302
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
using System.Security.Cryptography;
2+
using System.Text;
3+
using Coder.Desktop.Vpn.Service;
4+
using Microsoft.Extensions.Logging.Abstractions;
5+
6+
namespace Coder.Desktop.Tests.Vpn.Service;
7+
8+
public class TestDownloadValidator(Exception e) : IDownloadValidator
9+
{
10+
public Task ValidateAsync(string path, CancellationToken ct = default)
11+
{
12+
throw e;
13+
}
14+
}
15+
16+
[TestFixture]
17+
public class AuthenticodeDownloadValidatorTest
18+
{
19+
[Test(Description = "Test an unsigned binary")]
20+
[CancelAfter(30_000)]
21+
public void Unsigned(CancellationToken ct)
22+
{
23+
// TODO: this
24+
}
25+
26+
[Test(Description = "Test an untrusted binary")]
27+
[CancelAfter(30_000)]
28+
public void Untrusted(CancellationToken ct)
29+
{
30+
// TODO: this
31+
}
32+
33+
[Test(Description = "Test an binary with a detached signature (catalog file)")]
34+
[CancelAfter(30_000)]
35+
public void DifferentCertTrusted(CancellationToken ct)
36+
{
37+
// notepad.exe uses a catalog file for its signature.
38+
var ex = Assert.ThrowsAsync<Exception>(() =>
39+
AuthenticodeDownloadValidator.Coder.ValidateAsync(@"C:\Windows\System32\notepad.exe", ct));
40+
Assert.That(ex.Message,
41+
Does.Contain("File is not signed with an embedded Authenticode signature: Kind=Catalog"));
42+
}
43+
44+
[Test(Description = "Test a binary signed by a different certificate")]
45+
[CancelAfter(30_000)]
46+
public void DifferentCertUntrusted(CancellationToken ct)
47+
{
48+
// TODO: this
49+
}
50+
51+
[Test(Description = "Test a binary signed by Coder's certificate")]
52+
[CancelAfter(30_000)]
53+
public async Task CoderSigned(CancellationToken ct)
54+
{
55+
// TODO: this
56+
await Task.CompletedTask;
57+
}
58+
}
59+
60+
[TestFixture]
61+
public class DownloaderTest
62+
{
63+
// FYI, SetUp and TearDown get called before and after each test.
64+
[SetUp]
65+
public void Setup()
66+
{
67+
_tempDir = Path.Combine(Path.GetTempPath(), "Coder.Desktop.Tests.Vpn.Service_" + Path.GetRandomFileName());
68+
Directory.CreateDirectory(_tempDir);
69+
}
70+
71+
[TearDown]
72+
public void TearDown()
73+
{
74+
Directory.Delete(_tempDir, true);
75+
}
76+
77+
private string _tempDir;
78+
79+
private static TestHttpServer EchoServer()
80+
{
81+
// Create webserver that replies to `/xyz` with a test file containing
82+
// `xyz`.
83+
return new TestHttpServer(async ctx =>
84+
{
85+
// Get the path without the leading slash.
86+
var path = ctx.Request.Url!.AbsolutePath[1..];
87+
var pathBytes = Encoding.UTF8.GetBytes(path);
88+
89+
// If the client sends an If-None-Match header with the correct ETag,
90+
// return 304 Not Modified.
91+
var etag = "\"" + Convert.ToHexString(SHA1.HashData(pathBytes)).ToLower() + "\"";
92+
if (ctx.Request.Headers["If-None-Match"] == etag)
93+
{
94+
ctx.Response.StatusCode = 304;
95+
return;
96+
}
97+
98+
ctx.Response.StatusCode = 200;
99+
ctx.Response.Headers.Add("ETag", etag);
100+
ctx.Response.ContentType = "text/plain";
101+
ctx.Response.ContentLength64 = pathBytes.Length;
102+
await ctx.Response.OutputStream.WriteAsync(pathBytes);
103+
});
104+
}
105+
106+
[Test(Description = "Perform a download")]
107+
[CancelAfter(30_000)]
108+
public async Task Download(CancellationToken ct)
109+
{
110+
using var httpServer = EchoServer();
111+
var url = new Uri(httpServer.BaseUrl + "/test");
112+
var destPath = Path.Combine(_tempDir, "test");
113+
114+
var manager = new Downloader(NullLogger<Downloader>.Instance);
115+
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
116+
NullDownloadValidator.Instance, ct);
117+
await dlTask.Task;
118+
Assert.That(dlTask.TotalBytes, Is.EqualTo(4));
119+
Assert.That(dlTask.BytesRead, Is.EqualTo(4));
120+
Assert.That(dlTask.Progress, Is.EqualTo(1));
121+
Assert.That(dlTask.IsCompleted, Is.True);
122+
Assert.That(await File.ReadAllTextAsync(destPath, ct), Is.EqualTo("test"));
123+
}
124+
125+
[Test(Description = "Download with custom headers")]
126+
[CancelAfter(30_000)]
127+
public async Task WithHeaders(CancellationToken ct)
128+
{
129+
using var httpServer = new TestHttpServer(ctx =>
130+
{
131+
Assert.That(ctx.Request.Headers["X-Custom-Header"], Is.EqualTo("custom-value"));
132+
ctx.Response.StatusCode = 200;
133+
});
134+
var url = new Uri(httpServer.BaseUrl + "/test");
135+
var destPath = Path.Combine(_tempDir, "test");
136+
137+
var manager = new Downloader(NullLogger<Downloader>.Instance);
138+
var req = new HttpRequestMessage(HttpMethod.Get, url);
139+
req.Headers.Add("X-Custom-Header", "custom-value");
140+
var dlTask = await manager.StartDownloadAsync(req, destPath, NullDownloadValidator.Instance, ct);
141+
await dlTask.Task;
142+
}
143+
144+
[Test(Description = "Perform a download against an existing identical file")]
145+
[CancelAfter(30_000)]
146+
public async Task DownloadExisting(CancellationToken ct)
147+
{
148+
using var httpServer = EchoServer();
149+
var url = new Uri(httpServer.BaseUrl + "/test");
150+
var destPath = Path.Combine(_tempDir, "test");
151+
152+
// Create the destination file with a very old timestamp.
153+
await File.WriteAllTextAsync(destPath, "test", ct);
154+
File.SetLastWriteTime(destPath, DateTime.Now - TimeSpan.FromDays(365));
155+
156+
var manager = new Downloader(NullLogger<Downloader>.Instance);
157+
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
158+
NullDownloadValidator.Instance, ct);
159+
await dlTask.Task;
160+
Assert.That(dlTask.BytesRead, Is.Zero);
161+
Assert.That(await File.ReadAllTextAsync(destPath, ct), Is.EqualTo("test"));
162+
Assert.That(File.GetLastWriteTime(destPath), Is.LessThan(DateTime.Now - TimeSpan.FromDays(1)));
163+
}
164+
165+
[Test(Description = "Perform a download against an existing file with different content")]
166+
[CancelAfter(30_000)]
167+
public async Task DownloadExistingDifferentContent(CancellationToken ct)
168+
{
169+
using var httpServer = EchoServer();
170+
var url = new Uri(httpServer.BaseUrl + "/test");
171+
var destPath = Path.Combine(_tempDir, "test");
172+
173+
// Create the destination file with a very old timestamp.
174+
await File.WriteAllTextAsync(destPath, "TEST", ct);
175+
File.SetLastWriteTime(destPath, DateTime.Now - TimeSpan.FromDays(365));
176+
177+
var manager = new Downloader(NullLogger<Downloader>.Instance);
178+
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
179+
NullDownloadValidator.Instance, ct);
180+
await dlTask.Task;
181+
Assert.That(dlTask.BytesRead, Is.EqualTo(4));
182+
Assert.That(await File.ReadAllTextAsync(destPath, ct), Is.EqualTo("test"));
183+
Assert.That(File.GetLastWriteTime(destPath), Is.GreaterThan(DateTime.Now - TimeSpan.FromDays(1)));
184+
}
185+
186+
[Test(Description = "Unexpected response code from server")]
187+
[CancelAfter(30_000)]
188+
public void UnexpectedResponseCode(CancellationToken ct)
189+
{
190+
using var httpServer = new TestHttpServer(ctx => { ctx.Response.StatusCode = 404; });
191+
var url = new Uri(httpServer.BaseUrl + "/test");
192+
var destPath = Path.Combine(_tempDir, "test");
193+
194+
var manager = new Downloader(NullLogger<Downloader>.Instance);
195+
// The "outer" Task should fail.
196+
var ex = Assert.ThrowsAsync<HttpRequestException>(async () =>
197+
await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
198+
NullDownloadValidator.Instance, ct));
199+
Assert.That(ex.Message, Does.Contain("404"));
200+
}
201+
202+
// TODO: It would be nice to have a test that tests mismatched
203+
// Content-Length, but it seems HttpListener doesn't allow that.
204+
205+
[Test(Description = "Mismatched ETag")]
206+
[CancelAfter(30_000)]
207+
public async Task MismatchedETag(CancellationToken ct)
208+
{
209+
using var httpServer = new TestHttpServer(ctx =>
210+
{
211+
ctx.Response.StatusCode = 200;
212+
ctx.Response.Headers.Add("ETag", "\"beef\"");
213+
});
214+
var url = new Uri(httpServer.BaseUrl + "/test");
215+
var destPath = Path.Combine(_tempDir, "test");
216+
217+
var manager = new Downloader(NullLogger<Downloader>.Instance);
218+
// The "inner" Task should fail.
219+
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
220+
NullDownloadValidator.Instance, ct);
221+
var ex = Assert.ThrowsAsync<HttpRequestException>(async () => await dlTask.Task);
222+
Assert.That(ex.Message, Does.Contain("ETag does not match SHA1 hash of downloaded file").And.Contains("beef"));
223+
}
224+
225+
[Test(Description = "Timeout on response headers")]
226+
[CancelAfter(30_000)]
227+
public void CancelledOuter(CancellationToken ct)
228+
{
229+
using var httpServer = new TestHttpServer(async _ => { await Task.Delay(TimeSpan.FromSeconds(5), ct); });
230+
var url = new Uri(httpServer.BaseUrl + "/test");
231+
var destPath = Path.Combine(_tempDir, "test");
232+
233+
var manager = new Downloader(NullLogger<Downloader>.Instance);
234+
// The "outer" Task should fail.
235+
var smallerCt = new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token;
236+
Assert.ThrowsAsync<TaskCanceledException>(
237+
async () => await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
238+
NullDownloadValidator.Instance, smallerCt));
239+
}
240+
241+
[Test(Description = "Timeout on response body")]
242+
[CancelAfter(30_000)]
243+
public async Task CancelledInner(CancellationToken ct)
244+
{
245+
using var httpServer = new TestHttpServer(async ctx =>
246+
{
247+
ctx.Response.StatusCode = 200;
248+
await ctx.Response.OutputStream.WriteAsync("test"u8.ToArray(), ct);
249+
await ctx.Response.OutputStream.FlushAsync(ct);
250+
await Task.Delay(TimeSpan.FromSeconds(5), ct);
251+
});
252+
var url = new Uri(httpServer.BaseUrl + "/test");
253+
var destPath = Path.Combine(_tempDir, "test");
254+
255+
var manager = new Downloader(NullLogger<Downloader>.Instance);
256+
// The "inner" Task should fail.
257+
var smallerCt = new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token;
258+
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
259+
NullDownloadValidator.Instance, smallerCt);
260+
var ex = Assert.ThrowsAsync<TaskCanceledException>(async () => await dlTask.Task);
261+
Assert.That(ex.CancellationToken, Is.EqualTo(smallerCt));
262+
}
263+
264+
[Test(Description = "Validation failure")]
265+
[CancelAfter(30_000)]
266+
public async Task ValidationFailure(CancellationToken ct)
267+
{
268+
using var httpServer = EchoServer();
269+
var url = new Uri(httpServer.BaseUrl + "/test");
270+
var destPath = Path.Combine(_tempDir, "test");
271+
272+
var manager = new Downloader(NullLogger<Downloader>.Instance);
273+
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
274+
new TestDownloadValidator(new Exception("test exception")), ct);
275+
276+
var ex = Assert.ThrowsAsync<HttpRequestException>(async () => await dlTask.Task);
277+
Assert.That(ex.Message, Does.Contain("Downloaded file failed validation"));
278+
Assert.That(ex.InnerException, Is.Not.Null);
279+
Assert.That(ex.InnerException!.Message, Is.EqualTo("test exception"));
280+
}
281+
282+
[Test(Description = "Validation failure on existing file")]
283+
[CancelAfter(30_000)]
284+
public async Task ValidationFailureExistingFile(CancellationToken ct)
285+
{
286+
using var httpServer = EchoServer();
287+
var url = new Uri(httpServer.BaseUrl + "/test");
288+
var destPath = Path.Combine(_tempDir, "test");
289+
await File.WriteAllTextAsync(destPath, "test", ct);
290+
291+
var manager = new Downloader(NullLogger<Downloader>.Instance);
292+
// The "outer" Task should fail because the inner task never starts.
293+
var ex = Assert.ThrowsAsync<Exception>(async () =>
294+
{
295+
await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
296+
new TestDownloadValidator(new Exception("test exception")), ct);
297+
});
298+
Assert.That(ex.Message, Does.Contain("Existing file failed validation"));
299+
Assert.That(ex.InnerException, Is.Not.Null);
300+
Assert.That(ex.InnerException!.Message, Is.EqualTo("test exception"));
301+
}
302+
}

0 commit comments

Comments
 (0)