Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 4c6d2bd

Browse files
authoredJan 31, 2025··
fix: various fixes for tunnel startup to work (#14)
Fixes various problems that prevented tunnel startup from succeeding and prevented clients from connecting to the RPC server. Added a new temporary package Vpn.DebugClient to help control the system service component until the UI is ready. Adds utility PS1 scripts for adding the debug binary to a new system service and managing/hotswapping it. Start and stop operations from a client now reliably work, although there are various TODOs around the service codebase still. Closes #2
1 parent 7f716c8 commit 4c6d2bd

19 files changed

+744
-124
lines changed
 

‎Coder.Desktop.sln

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoderSdk", "CoderSdk\CoderS
1919
EndProject
2020
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "App\App.csproj", "{800C7E2D-0D86-4554-9903-B879DA6FA2CE}"
2121
EndProject
22+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vpn.DebugClient", "Vpn.DebugClient\Vpn.DebugClient.csproj", "{1BBFDF88-B25F-4C07-99C2-30DA384DB730}"
23+
EndProject
2224
Global
2325
GlobalSection(SolutionConfigurationPlatforms) = preSolution
2426
Debug|Any CPU = Debug|Any CPU
@@ -167,6 +169,22 @@ Global
167169
{800C7E2D-0D86-4554-9903-B879DA6FA2CE}.Release|x86.ActiveCfg = Release|x86
168170
{800C7E2D-0D86-4554-9903-B879DA6FA2CE}.Release|x86.Build.0 = Release|x86
169171
{800C7E2D-0D86-4554-9903-B879DA6FA2CE}.Release|x86.Deploy.0 = Release|x86
172+
{1BBFDF88-B25F-4C07-99C2-30DA384DB730}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
173+
{1BBFDF88-B25F-4C07-99C2-30DA384DB730}.Debug|Any CPU.Build.0 = Debug|Any CPU
174+
{1BBFDF88-B25F-4C07-99C2-30DA384DB730}.Debug|ARM64.ActiveCfg = Debug|Any CPU
175+
{1BBFDF88-B25F-4C07-99C2-30DA384DB730}.Debug|ARM64.Build.0 = Debug|Any CPU
176+
{1BBFDF88-B25F-4C07-99C2-30DA384DB730}.Debug|x64.ActiveCfg = Debug|Any CPU
177+
{1BBFDF88-B25F-4C07-99C2-30DA384DB730}.Debug|x64.Build.0 = Debug|Any CPU
178+
{1BBFDF88-B25F-4C07-99C2-30DA384DB730}.Debug|x86.ActiveCfg = Debug|Any CPU
179+
{1BBFDF88-B25F-4C07-99C2-30DA384DB730}.Debug|x86.Build.0 = Debug|Any CPU
180+
{1BBFDF88-B25F-4C07-99C2-30DA384DB730}.Release|Any CPU.ActiveCfg = Release|Any CPU
181+
{1BBFDF88-B25F-4C07-99C2-30DA384DB730}.Release|Any CPU.Build.0 = Release|Any CPU
182+
{1BBFDF88-B25F-4C07-99C2-30DA384DB730}.Release|ARM64.ActiveCfg = Release|Any CPU
183+
{1BBFDF88-B25F-4C07-99C2-30DA384DB730}.Release|ARM64.Build.0 = Release|Any CPU
184+
{1BBFDF88-B25F-4C07-99C2-30DA384DB730}.Release|x64.ActiveCfg = Release|Any CPU
185+
{1BBFDF88-B25F-4C07-99C2-30DA384DB730}.Release|x64.Build.0 = Release|Any CPU
186+
{1BBFDF88-B25F-4C07-99C2-30DA384DB730}.Release|x86.ActiveCfg = Release|Any CPU
187+
{1BBFDF88-B25F-4C07-99C2-30DA384DB730}.Release|x86.Build.0 = Release|Any CPU
170188
EndGlobalSection
171189
GlobalSection(SolutionProperties) = preSolution
172190
HideSolutionNode = FALSE

‎Coder.Desktop.sln.DotSettings

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
</HasMember>
3131
</And>
3232
</TypePattern.Match>
33-
33+
3434
<Entry DisplayName="Fields">
3535
<Entry.Match>
3636
<And>
@@ -144,10 +144,6 @@
144144
<Kind Is="Delegate" />
145145
</And>
146146
</Entry.Match>
147-
148-
<Entry.SortBy>
149-
<Name />
150-
</Entry.SortBy>
151147
</Entry>
152148

153149
<Entry DisplayName="Public Enums" Priority="100">
@@ -157,10 +153,6 @@
157153
<Kind Is="Enum" />
158154
</And>
159155
</Entry.Match>
160-
161-
<Entry.SortBy>
162-
<Name />
163-
</Entry.SortBy>
164156
</Entry>
165157

166158
<Entry DisplayName="Static Fields and Constants">
@@ -193,21 +185,12 @@
193185
</Not>
194186
</And>
195187
</Entry.Match>
196-
197-
<Entry.SortBy>
198-
<Readonly />
199-
<Name />
200-
</Entry.SortBy>
201188
</Entry>
202189

203190
<Entry DisplayName="Events">
204191
<Entry.Match>
205192
<Kind Is="Event" />
206193
</Entry.Match>
207-
208-
<Entry.SortBy>
209-
<Name />
210-
</Entry.SortBy>
211194
</Entry>
212195

213196
<Entry DisplayName="Constructors">
@@ -256,4 +239,4 @@
256239
<s:Boolean x:Key="/Default/UserDictionary/Words/=codervpn/@EntryIndexedValue">True</s:Boolean>
257240
<s:Boolean x:Key="/Default/UserDictionary/Words/=hkey/@EntryIndexedValue">True</s:Boolean>
258241
<s:Boolean x:Key="/Default/UserDictionary/Words/=replyable/@EntryIndexedValue">True</s:Boolean>
259-
<s:Boolean x:Key="/Default/UserDictionary/Words/=serdes/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
242+
<s:Boolean x:Key="/Default/UserDictionary/Words/=serdes/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

‎Tests.Vpn.Service/packages.lock.json

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,48 @@
383383
"Microsoft.Extensions.Primitives": "5.0.1"
384384
}
385385
},
386+
"Serilog": {
387+
"type": "Transitive",
388+
"resolved": "4.2.0",
389+
"contentHash": "gmoWVOvKgbME8TYR+gwMf7osROiWAURterc6Rt2dQyX7wtjZYpqFiA/pY6ztjGQKKV62GGCyOcmtP1UKMHgSmA=="
390+
},
391+
"Serilog.Extensions.Hosting": {
392+
"type": "Transitive",
393+
"resolved": "9.0.0",
394+
"contentHash": "u2TRxuxbjvTAldQn7uaAwePkWxTHIqlgjelekBtilAGL5sYyF3+65NWctN4UrwwGLsDC7c3Vz3HnOlu+PcoxXg==",
395+
"dependencies": {
396+
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
397+
"Microsoft.Extensions.Hosting.Abstractions": "9.0.0",
398+
"Microsoft.Extensions.Logging.Abstractions": "9.0.0",
399+
"Serilog": "4.2.0",
400+
"Serilog.Extensions.Logging": "9.0.0"
401+
}
402+
},
403+
"Serilog.Extensions.Logging": {
404+
"type": "Transitive",
405+
"resolved": "9.0.0",
406+
"contentHash": "NwSSYqPJeKNzl5AuXVHpGbr6PkZJFlNa14CdIebVjK3k/76kYj/mz5kiTRNVSsSaxM8kAIa1kpy/qyT9E4npRQ==",
407+
"dependencies": {
408+
"Microsoft.Extensions.Logging": "9.0.0",
409+
"Serilog": "4.2.0"
410+
}
411+
},
412+
"Serilog.Sinks.Console": {
413+
"type": "Transitive",
414+
"resolved": "6.0.0",
415+
"contentHash": "fQGWqVMClCP2yEyTXPIinSr5c+CBGUvBybPxjAGcf7ctDhadFhrQw03Mv8rJ07/wR5PDfFjewf2LimvXCDzpbA==",
416+
"dependencies": {
417+
"Serilog": "4.0.0"
418+
}
419+
},
420+
"Serilog.Sinks.File": {
421+
"type": "Transitive",
422+
"resolved": "6.0.0",
423+
"contentHash": "lxjg89Y8gJMmFxVkbZ+qDgjl+T4yC5F7WSLTvA+5q0R04tfKVLRL/EHpYoJ/MEQd2EeCKDuylBIVnAYMotmh2A==",
424+
"dependencies": {
425+
"Serilog": "4.0.0"
426+
}
427+
},
386428
"System.Diagnostics.DiagnosticSource": {
387429
"type": "Transitive",
388430
"resolved": "9.0.1",
@@ -450,6 +492,9 @@
450492
"Microsoft.Extensions.Options.DataAnnotations": "[9.0.1, )",
451493
"Microsoft.Security.Extensions": "[1.3.0, )",
452494
"Semver": "[3.0.0, )",
495+
"Serilog.Extensions.Hosting": "[9.0.0, )",
496+
"Serilog.Sinks.Console": "[6.0.0, )",
497+
"Serilog.Sinks.File": "[6.0.0, )",
453498
"Vpn": "[1.0.0, )"
454499
}
455500
}

‎Vpn.DebugClient/Program.cs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
using System.IO.Pipes;
2+
using Coder.Desktop.Vpn.Proto;
3+
4+
namespace Coder.Desktop.Vpn.DebugClient;
5+
6+
public static class Program
7+
{
8+
private static Speaker<ClientMessage, ServiceMessage>? _speaker;
9+
10+
private static string? _coderUrl;
11+
private static string? _apiToken;
12+
13+
public static void Main()
14+
{
15+
Console.WriteLine("Type 'exit' to exit the program");
16+
Console.WriteLine("Type 'connect' to connect to the service");
17+
Console.WriteLine("Type 'disconnect' to disconnect from the service");
18+
Console.WriteLine("Type 'configure' to set the parameters");
19+
Console.WriteLine("Type 'start' to send a start command with the current parameters");
20+
Console.WriteLine("Type 'stop' to send a stop command");
21+
while (true)
22+
{
23+
Console.Write("> ");
24+
var input = Console.ReadLine()?.Trim();
25+
try
26+
{
27+
switch (input)
28+
{
29+
case "exit":
30+
return;
31+
case "connect":
32+
Connect();
33+
break;
34+
case "disconnect":
35+
Disconnect();
36+
break;
37+
case "configure":
38+
Configure();
39+
break;
40+
case "start":
41+
Start();
42+
break;
43+
case "stop":
44+
Stop();
45+
break;
46+
}
47+
}
48+
catch (Exception ex)
49+
{
50+
Console.WriteLine($"Error: {ex}");
51+
}
52+
}
53+
}
54+
55+
private static void Connect()
56+
{
57+
var client = new NamedPipeClientStream(".", "Coder.Desktop.Vpn", PipeDirection.InOut, PipeOptions.Asynchronous);
58+
client.Connect();
59+
Console.WriteLine("Connected to named pipe.");
60+
61+
_speaker = new Speaker<ClientMessage, ServiceMessage>(client);
62+
_speaker.Receive += message => { Console.WriteLine($"Received({message.Message.MsgCase}: {message.Message}"); };
63+
_speaker.Error += exception =>
64+
{
65+
Console.WriteLine($"Error: {exception}");
66+
Disconnect();
67+
};
68+
_speaker.StartAsync().Wait();
69+
Console.WriteLine("Speaker started.");
70+
}
71+
72+
private static void Disconnect()
73+
{
74+
_speaker?.DisposeAsync().AsTask().Wait();
75+
_speaker = null;
76+
Console.WriteLine("Disconnected from named pipe");
77+
}
78+
79+
private static void Configure()
80+
{
81+
Console.Write("Coder URL: ");
82+
_coderUrl = Console.ReadLine()?.Trim();
83+
Console.Write("API Token: ");
84+
_apiToken = Console.ReadLine()?.Trim();
85+
}
86+
87+
private static void Start()
88+
{
89+
if (_speaker is null)
90+
{
91+
Console.WriteLine("Not connected to Coder.Desktop.Vpn.");
92+
return;
93+
}
94+
95+
var message = new ClientMessage
96+
{
97+
Start = new StartRequest
98+
{
99+
CoderUrl = _coderUrl,
100+
ApiToken = _apiToken,
101+
},
102+
};
103+
Console.WriteLine("Sending start message...");
104+
var sendTask = _speaker.SendRequestAwaitReply(message).AsTask();
105+
Console.WriteLine("Start message sent, awaiting reply.");
106+
sendTask.Wait();
107+
Console.WriteLine($"Received reply: {sendTask.Result.Message}");
108+
}
109+
110+
private static void Stop()
111+
{
112+
if (_speaker is null)
113+
{
114+
Console.WriteLine("Not connected to Coder.Desktop.Vpn.");
115+
return;
116+
}
117+
118+
var message = new ClientMessage
119+
{
120+
Stop = new StopRequest(),
121+
};
122+
Console.WriteLine("Sending stop message...");
123+
var sendTask = _speaker.SendRequestAwaitReply(message);
124+
Console.WriteLine("Stop message sent, awaiting reply.");
125+
var reply = sendTask.AsTask().Result;
126+
Console.WriteLine($"Received reply: {reply.Message}");
127+
}
128+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<RootNamespace>Coder.Desktop.Vpn.DebugClient</RootNamespace>
5+
<OutputType>Exe</OutputType>
6+
<TargetFramework>net8.0</TargetFramework>
7+
<ImplicitUsings>enable</ImplicitUsings>
8+
<Nullable>enable</Nullable>
9+
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
10+
</PropertyGroup>
11+
12+
<ItemGroup>
13+
<ProjectReference Include="..\Vpn\Vpn.csproj"/>
14+
<ProjectReference Include="..\Vpn.Proto\Vpn.Proto.csproj"/>
15+
</ItemGroup>
16+
17+
</Project>

‎Vpn.DebugClient/packages.lock.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"version": 1,
3+
"dependencies": {
4+
"net8.0": {
5+
"Google.Protobuf": {
6+
"type": "Transitive",
7+
"resolved": "3.29.3",
8+
"contentHash": "t7nZFFUFwigCwZ+nIXHDLweXvwIpsOXi+P7J7smPT/QjI3EKxnCzTQOhBqyEh6XEzc/pNH+bCFOOSjatrPt6Tw=="
9+
},
10+
"System.IO.Pipelines": {
11+
"type": "Transitive",
12+
"resolved": "9.0.1",
13+
"contentHash": "uXf5o8eV/gtzDQY4lGROLFMWQvcViKcF8o4Q6KpIOjloAQXrnscQSu6gTxYJMHuNJnh7szIF9AzkaEq+zDLoEg=="
14+
},
15+
"vpn": {
16+
"type": "Project",
17+
"dependencies": {
18+
"System.IO.Pipelines": "[9.0.1, )",
19+
"Vpn.Proto": "[1.0.0, )"
20+
}
21+
},
22+
"vpn.proto": {
23+
"type": "Project",
24+
"dependencies": {
25+
"Google.Protobuf": "[3.29.3, )"
26+
}
27+
}
28+
}
29+
}
30+
}

‎Vpn.Service/Create-Service.ps1

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Elevate to administrator
2+
if (-not ([Security.Principal.WindowsPrincipal]([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
3+
Write-Host "Elevating script to run as administrator..."
4+
Start-Process powershell.exe -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$($MyInvocation.MyCommand.Path)`"" -Verb RunAs
5+
exit
6+
}
7+
8+
$name = "Coder Desktop (Debug)"
9+
$binaryPath = Join-Path -Path $PSScriptRoot -ChildPath "bin/Debug/net8.0-windows/Vpn.Service.exe"
10+
11+
try {
12+
Write-Host "Creating service..."
13+
New-Service -Name $name -BinaryPathName "`"$binaryPath`"" -DisplayName $name -StartupType Automatic
14+
15+
$sddl = & sc.exe sdshow $name
16+
if (-not $sddl) {
17+
throw "Failed to retrieve security descriptor for service '$name'"
18+
}
19+
Write-Host "Current security descriptor: '$sddl'"
20+
$sddl = $sddl.Trim() -replace "D:", "D:(A;;RPWP;;;WD)" # allow everyone to start, stop, pause, and query the service
21+
Write-Host "Setting security descriptor: '$sddl'"
22+
& sc.exe sdset $name $sddl
23+
24+
Write-Host "Starting service..."
25+
Start-Service -Name $name
26+
27+
if ((Get-Service -Name $name -ErrorAction Stop).Status -ne "Running") {
28+
throw "Service '$name' is not running"
29+
}
30+
Write-Host "Service '$name' created and started successfully"
31+
} catch {
32+
Write-Host $_ -ForegroundColor Red
33+
Write-Host "Press Return to exit..."
34+
Read-Host
35+
}

‎Vpn.Service/Delete-Service.ps1

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Elevate to administrator
2+
if (-not ([Security.Principal.WindowsPrincipal]([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
3+
Write-Host "Elevating script to run as administrator..."
4+
Start-Process powershell.exe -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$($MyInvocation.MyCommand.Path)`"" -Verb RunAs
5+
exit
6+
}
7+
8+
$name = "Coder Desktop (Debug)"
9+
10+
try {
11+
Stop-Service -Name $name -Force -ErrorAction SilentlyContinue
12+
sc.exe delete $name
13+
Write-Host "Service '$name' deleted"
14+
} catch {
15+
Write-Host $_ -ForegroundColor Red
16+
Write-Host "Press Return to exit..."
17+
Read-Host
18+
}

‎Vpn.Service/Manager.cs

Lines changed: 133 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Runtime.InteropServices;
22
using Coder.Desktop.Vpn.Proto;
3+
using Coder.Desktop.Vpn.Utilities;
34
using CoderSdk;
45
using Microsoft.Extensions.Logging;
56
using Microsoft.Extensions.Options;
@@ -21,13 +22,19 @@ public Task HandleClientRpcMessage(ReplyableRpcMessage<ServiceMessage, ClientMes
2122
public class Manager : IManager
2223
{
2324
// TODO: determine a suitable value for this
24-
private const string ServerVersionRange = ">=0.0.0";
25+
private static readonly SemVersionRange ServerVersionRange = SemVersionRange.All;
2526

2627
private readonly ManagerConfig _config;
2728
private readonly IDownloader _downloader;
2829
private readonly ILogger<Manager> _logger;
2930
private readonly ITunnelSupervisor _tunnelSupervisor;
3031

32+
// TunnelSupervisor already has protections against concurrent operations,
33+
// but all the other stuff before starting the tunnel does not.
34+
private readonly RaiiSemaphoreSlim _tunnelOperationLock = new(1, 1);
35+
private SemVersion? _lastServerVersion;
36+
private StartRequest? _lastStartRequest;
37+
3138
// ReSharper disable once ConvertToPrimaryConstructor
3239
public Manager(IOptions<ManagerConfig> config, ILogger<Manager> logger, IDownloader downloader,
3340
ITunnelSupervisor tunnelSupervisor)
@@ -52,15 +59,22 @@ public async Task HandleClientRpcMessage(ReplyableRpcMessage<ServiceMessage, Cli
5259
CancellationToken ct = default)
5360
{
5461
_logger.LogInformation("ClientMessage: {MessageType}", message.Message.MsgCase);
55-
// TODO: break out each into it's own method?
5662
switch (message.Message.MsgCase)
5763
{
5864
case ClientMessage.MsgOneofCase.Start:
5965
// TODO: these sub-methods should be managed by some Task list and cancelled/awaited on stop
60-
await HandleClientMessageStart(message, ct);
66+
var startResponse = await HandleClientMessageStart(message.Message, ct);
67+
await message.SendReply(new ServiceMessage
68+
{
69+
Start = startResponse,
70+
}, ct);
6171
break;
6272
case ClientMessage.MsgOneofCase.Stop:
63-
await HandleClientMessageStop(message, ct);
73+
var stopResponse = await HandleClientMessageStop(message.Message, ct);
74+
await message.SendReply(new ServiceMessage
75+
{
76+
Stop = stopResponse,
77+
}, ct);
6478
break;
6579
case ClientMessage.MsgOneofCase.None:
6680
default:
@@ -74,63 +88,105 @@ public async Task StopAsync(CancellationToken ct = default)
7488
await _tunnelSupervisor.StopAsync(ct);
7589
}
7690

77-
private async Task HandleClientMessageStart(ReplyableRpcMessage<ServiceMessage, ClientMessage> message,
91+
private async ValueTask<StartResponse> HandleClientMessageStart(ClientMessage message,
7892
CancellationToken ct)
7993
{
80-
try
94+
var opLock = await _tunnelOperationLock.LockAsync(TimeSpan.FromMilliseconds(500), ct);
95+
if (opLock == null)
8196
{
82-
// TODO: if the credentials and URL are identical and the server
83-
// version hasn't changed we should not do anything
84-
// TODO: this should be broken out into it's own method
85-
_logger.LogInformation("ClientMessage.Start: testing server '{ServerUrl}'", message.Message.Start.CoderUrl);
86-
var client = new CoderApiClient(message.Message.Start.CoderUrl, message.Message.Start.ApiToken);
87-
var buildInfo = await client.GetBuildInfo(ct);
88-
_logger.LogInformation("ClientMessage.Start: server version '{ServerVersion}'", buildInfo.Version);
89-
var serverVersion = SemVersion.Parse(buildInfo.Version);
90-
if (!serverVersion.Satisfies(ServerVersionRange))
91-
throw new InvalidOperationException(
92-
$"Server version '{serverVersion}' is not within required server version range '{ServerVersionRange}'");
93-
var user = await client.GetUser(User.Me, ct);
94-
_logger.LogInformation("ClientMessage.Start: authenticated as '{Username}'", user.Username);
95-
96-
await DownloadTunnelBinaryAsync(message.Message.Start.CoderUrl, serverVersion, ct);
97-
await _tunnelSupervisor.StartAsync(_config.TunnelBinaryPath, HandleTunnelRpcMessage,
98-
HandleTunnelRpcError,
99-
ct);
97+
_logger.LogWarning("ClientMessage.Start: Tunnel operation lock timed out");
98+
return new StartResponse
99+
{
100+
Success = false,
101+
ErrorMessage = "Could not acquire tunnel operation lock, another operation is in progress",
102+
};
100103
}
101-
catch (Exception e)
104+
105+
using (opLock)
102106
{
103-
_logger.LogWarning(e, "ClientMessage.Start: Failed to start VPN client");
104-
await message.SendReply(new ServiceMessage
107+
try
105108
{
106-
Start = new StartResponse
109+
var serverVersion =
110+
await CheckServerVersionAndCredentials(message.Start.CoderUrl, message.Start.ApiToken,
111+
ct);
112+
if (_tunnelSupervisor.IsRunning && _lastStartRequest != null &&
113+
_lastStartRequest.Equals(message.Start) && _lastServerVersion == serverVersion)
114+
{
115+
// The client is requesting to start an identical tunnel while
116+
// we're already running it.
117+
_logger.LogInformation("ClientMessage.Start: Ignoring duplicate start request");
118+
return new StartResponse
119+
{
120+
Success = true,
121+
};
122+
}
123+
124+
_lastStartRequest = message.Start;
125+
_lastServerVersion = serverVersion;
126+
127+
// TODO: each section of this operation needs a timeout
128+
// Stop the tunnel if it's running so we don't have to worry about
129+
// permissions issues when replacing the binary.
130+
await _tunnelSupervisor.StopAsync(ct);
131+
await DownloadTunnelBinaryAsync(message.Start.CoderUrl, serverVersion, ct);
132+
await _tunnelSupervisor.StartAsync(_config.TunnelBinaryPath, HandleTunnelRpcMessage,
133+
HandleTunnelRpcError,
134+
ct);
135+
136+
var reply = await _tunnelSupervisor.SendRequestAwaitReply(new ManagerMessage
137+
{
138+
Start = message.Start,
139+
}, ct);
140+
if (reply.MsgCase != TunnelMessage.MsgOneofCase.Start)
141+
throw new InvalidOperationException("Tunnel did not reply with a Start response");
142+
return reply.Start;
143+
}
144+
catch (Exception e)
145+
{
146+
_logger.LogWarning(e, "ClientMessage.Start: Failed to start VPN client");
147+
return new StartResponse
107148
{
108149
Success = false,
109-
ErrorMessage = e.Message,
110-
},
111-
}, ct);
150+
ErrorMessage = e.ToString(),
151+
};
152+
}
112153
}
113154
}
114155

115-
private async Task HandleClientMessageStop(ReplyableRpcMessage<ServiceMessage, ClientMessage> message,
156+
private async ValueTask<StopResponse> HandleClientMessageStop(ClientMessage message,
116157
CancellationToken ct)
117158
{
118-
try
159+
var opLock = await _tunnelOperationLock.LockAsync(TimeSpan.FromMilliseconds(500), ct);
160+
if (opLock == null)
119161
{
120-
// This will handle sending the Stop message for us.
121-
await _tunnelSupervisor.StopAsync(ct);
162+
_logger.LogWarning("ClientMessage.Stop: Tunnel operation lock timed out");
163+
return new StopResponse
164+
{
165+
Success = false,
166+
ErrorMessage = "Could not acquire tunnel operation lock, another operation is in progress",
167+
};
122168
}
123-
catch (Exception e)
169+
170+
using (opLock)
124171
{
125-
_logger.LogWarning(e, "ClientMessage.Stop: Failed to stop VPN client");
126-
await message.SendReply(new ServiceMessage
172+
try
173+
{
174+
// This will handle sending the Stop message to the tunnel for us.
175+
await _tunnelSupervisor.StopAsync(ct);
176+
return new StopResponse
177+
{
178+
Success = true,
179+
};
180+
}
181+
catch (Exception e)
127182
{
128-
Stop = new StopResponse
183+
_logger.LogWarning(e, "ClientMessage.Stop: Failed to stop VPN client");
184+
return new StopResponse
129185
{
130186
Success = false,
131-
ErrorMessage = e.Message,
132-
},
133-
}, ct);
187+
ErrorMessage = e.ToString(),
188+
};
189+
}
134190
}
135191
}
136192

@@ -141,11 +197,11 @@ private void HandleTunnelRpcMessage(ReplyableRpcMessage<ManagerMessage, TunnelMe
141197

142198
private void HandleTunnelRpcError(Exception e)
143199
{
144-
// TODO: this probably happens during an ongoing start or stop operation, and we should definitely ignore those
145200
_logger.LogError(e, "Manager<->Tunnel RPC error");
146201
try
147202
{
148203
_tunnelSupervisor.StopAsync();
204+
// TODO: this should broadcast an update to all clients
149205
}
150206
catch (Exception e2)
151207
{
@@ -171,6 +227,34 @@ private static string SystemArchitecture()
171227
};
172228
}
173229

230+
/// <summary>
231+
/// Connects to the Coder server to ensure the server version is within the required range and the credentials
232+
/// are valid.
233+
/// </summary>
234+
/// <param name="baseUrl">Coder server base URL</param>
235+
/// <param name="apiToken">Coder API token</param>
236+
/// <param name="ct">Cancellation token</param>
237+
/// <returns>The server version</returns>
238+
/// <exception cref="InvalidOperationException">The server version is not within the required range</exception>
239+
private async ValueTask<SemVersion> CheckServerVersionAndCredentials(string baseUrl, string apiToken,
240+
CancellationToken ct = default)
241+
{
242+
var client = new CoderApiClient(baseUrl, apiToken);
243+
244+
var buildInfo = await client.GetBuildInfo(ct);
245+
_logger.LogInformation("Fetched server version '{ServerVersion}'", buildInfo.Version);
246+
if (buildInfo.Version.StartsWith('v')) buildInfo.Version = buildInfo.Version[1..];
247+
var serverVersion = SemVersion.Parse(buildInfo.Version);
248+
if (!serverVersion.Satisfies(ServerVersionRange))
249+
throw new InvalidOperationException(
250+
$"Server version '{serverVersion}' is not within required server version range '{ServerVersionRange}'");
251+
252+
var user = await client.GetUser(User.Me, ct);
253+
_logger.LogInformation("Authenticated to server as '{Username}'", user.Username);
254+
255+
return serverVersion;
256+
}
257+
174258
/// <summary>
175259
/// Fetches the "/bin/coder-windows-{architecture}.exe" binary from the given base URL and writes it to the
176260
/// destination path after validating the signature and checksum.
@@ -200,16 +284,17 @@ private async Task DownloadTunnelBinaryAsync(string baseUrl, SemVersion expected
200284
_config.TunnelBinaryPath);
201285
var req = new HttpRequestMessage(HttpMethod.Get, url);
202286
var validators = new CombinationDownloadValidator(
203-
AuthenticodeDownloadValidator.Coder,
204-
new AssemblyVersionDownloadValidator(
205-
$"{expectedVersion.Major}.{expectedVersion.Minor}.{expectedVersion.Patch}.0")
287+
// TODO: re-enable when the binaries are signed and have versions
288+
//AuthenticodeDownloadValidator.Coder,
289+
//new AssemblyVersionDownloadValidator(
290+
//$"{expectedVersion.Major}.{expectedVersion.Minor}.{expectedVersion.Patch}.0")
206291
);
207292
var downloadTask = await _downloader.StartDownloadAsync(req, _config.TunnelBinaryPath, validators, ct);
208293

209294
// TODO: monitor and report progress when we have a mechanism to do so
210295

211-
// Awaiting this will check the checksum (via the ETag) if provided,
212-
// and will also validate the signature using the validator we supplied.
296+
// Awaiting this will check the checksum (via the ETag) if the file
297+
// exists, and will also validate the signature and version.
213298
await downloadTask.Task;
214299
}
215300
}

‎Vpn.Service/ManagerRpcService.cs

Lines changed: 69 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,33 @@
11
using System.Collections.Concurrent;
22
using System.IO.Pipes;
3+
using System.Security.AccessControl;
4+
using System.Security.Principal;
35
using Coder.Desktop.Vpn.Proto;
46
using Microsoft.Extensions.Hosting;
57
using Microsoft.Extensions.Logging;
68
using Microsoft.Extensions.Options;
79

810
namespace Coder.Desktop.Vpn.Service;
911

12+
public class ManagerRpcClient(Speaker<ServiceMessage, ClientMessage> speaker, Task task)
13+
{
14+
public Speaker<ServiceMessage, ClientMessage> Speaker { get; } = speaker;
15+
public Task Task { get; } = task;
16+
}
17+
1018
/// <summary>
1119
/// Provides a named pipe server for communication between multiple RpcRole.Client and RpcRole.Manager.
1220
/// </summary>
1321
public class ManagerRpcService : BackgroundService, IAsyncDisposable
1422
{
15-
private readonly ConcurrentDictionary<int, Task> _activeClientTasks = new();
23+
private readonly ConcurrentDictionary<ulong, ManagerRpcClient> _activeClients = new();
1624
private readonly ManagerConfig _config;
1725
private readonly CancellationTokenSource _cts = new();
1826
private readonly ILogger<ManagerRpcService> _logger;
1927
private readonly IManager _manager;
28+
private ulong _lastClientId;
2029

30+
// ReSharper disable once ConvertToPrimaryConstructor
2131
public ManagerRpcService(IOptions<ManagerConfig> config, ILogger<ManagerRpcService> logger, IManager manager)
2232
{
2333
_logger = logger;
@@ -28,15 +38,15 @@ public ManagerRpcService(IOptions<ManagerConfig> config, ILogger<ManagerRpcServi
2838
public async ValueTask DisposeAsync()
2939
{
3040
await _cts.CancelAsync();
31-
while (!_activeClientTasks.IsEmpty) await Task.WhenAny(_activeClientTasks.Values);
41+
while (!_activeClients.IsEmpty) await Task.WhenAny(_activeClients.Values.Select(c => c.Task));
3242
_cts.Dispose();
3343
GC.SuppressFinalize(this);
3444
}
3545

3646
public override async Task StopAsync(CancellationToken cancellationToken)
3747
{
3848
await _cts.CancelAsync();
39-
while (!_activeClientTasks.IsEmpty) await Task.WhenAny(_activeClientTasks.Values);
49+
while (!_activeClients.IsEmpty) await Task.WhenAny(_activeClients.Values.Select(c => c.Task));
4050
}
4151

4252
/// <summary>
@@ -46,47 +56,59 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
4656
{
4757
_logger.LogInformation(@"Starting continuous named pipe RPC server at \\.\pipe\{PipeName}",
4858
_config.ServiceRpcPipeName);
59+
60+
// Allow everyone to connect to the named pipe
61+
var pipeSecurity = new PipeSecurity();
62+
pipeSecurity.AddAccessRule(new PipeAccessRule(
63+
new SecurityIdentifier(WellKnownSidType.WorldSid, null),
64+
PipeAccessRights.FullControl,
65+
AccessControlType.Allow));
66+
67+
// Starting a named pipe server is not like a TCP server where you can
68+
// continuously accept new connections. You need to recreate the server
69+
// after accepting a connection in order to accept new connections.
4970
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _cts.Token);
5071
while (!linkedCts.IsCancellationRequested)
5172
{
52-
var pipeServer = new NamedPipeServerStream(_config.ServiceRpcPipeName, PipeDirection.InOut,
53-
NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
73+
var pipeServer = NamedPipeServerStreamAcl.Create(_config.ServiceRpcPipeName, PipeDirection.InOut,
74+
NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte, PipeOptions.Asynchronous, 0,
75+
0, pipeSecurity);
5476

5577
try
5678
{
57-
try
58-
{
59-
_logger.LogDebug("Waiting for new named pipe client connection");
60-
await pipeServer.WaitForConnectionAsync(linkedCts.Token);
61-
}
62-
finally
63-
{
64-
await pipeServer.DisposeAsync();
65-
}
79+
_logger.LogDebug("Waiting for new named pipe client connection");
80+
await pipeServer.WaitForConnectionAsync(linkedCts.Token);
6681

67-
_logger.LogInformation("Handling named pipe client connection");
68-
var clientTask = HandleRpcClientAsync(pipeServer, linkedCts.Token);
69-
_activeClientTasks.TryAdd(clientTask.Id, clientTask);
70-
_ = clientTask.ContinueWith(RpcClientContinuation, CancellationToken.None);
82+
var clientId = Interlocked.Add(ref _lastClientId, 1);
83+
_logger.LogInformation("Handling named pipe client connection for client {ClientId}", clientId);
84+
var speaker = new Speaker<ServiceMessage, ClientMessage>(pipeServer);
85+
var clientTask = HandleRpcClientAsync(speaker, linkedCts.Token);
86+
_activeClients.TryAdd(clientId, new ManagerRpcClient(speaker, clientTask));
87+
_ = clientTask.ContinueWith(task =>
88+
{
89+
if (task.IsFaulted)
90+
_logger.LogWarning(task.Exception, "Client {ClientId} RPC task faulted", clientId);
91+
_activeClients.TryRemove(clientId, out _);
92+
}, CancellationToken.None);
7193
}
7294
catch (OperationCanceledException)
7395
{
96+
await pipeServer.DisposeAsync();
7497
throw;
7598
}
7699
catch (Exception e)
77100
{
78101
_logger.LogWarning(e, "Failed to accept named pipe client");
102+
await pipeServer.DisposeAsync();
79103
}
80104
}
81105
}
82106

83-
private async Task HandleRpcClientAsync(NamedPipeServerStream pipeServer, CancellationToken ct)
107+
private async Task HandleRpcClientAsync(Speaker<ServiceMessage, ClientMessage> speaker, CancellationToken ct)
84108
{
85109
var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token);
86-
await using (pipeServer)
110+
await using (speaker)
87111
{
88-
await using var speaker = new Speaker<ServiceMessage, ClientMessage>(pipeServer);
89-
90112
var tcs = new TaskCompletionSource();
91113
var activeTasks = new ConcurrentDictionary<int, Task>();
92114
speaker.Receive += msg =>
@@ -101,6 +123,7 @@ private async Task HandleRpcClientAsync(NamedPipeServerStream pipeServer, Cancel
101123
}, CancellationToken.None);
102124
};
103125
speaker.Error += tcs.SetException;
126+
speaker.Error += exception => { _logger.LogWarning(exception, "Client RPC speaker error"); };
104127
await using (ct.Register(() => tcs.SetCanceled(ct)))
105128
{
106129
await speaker.StartAsync(ct);
@@ -112,17 +135,34 @@ private async Task HandleRpcClientAsync(NamedPipeServerStream pipeServer, Cancel
112135
}
113136
}
114137

115-
private void RpcClientContinuation(Task task)
116-
{
117-
if (task.IsFaulted)
118-
_logger.LogWarning(task.Exception, "Client RPC task faulted");
119-
_activeClientTasks.TryRemove(task.Id, out _);
120-
}
121-
122138
private async Task HandleRpcMessageAsync(ReplyableRpcMessage<ServiceMessage, ClientMessage> message,
123139
CancellationToken ct)
124140
{
125141
_logger.LogInformation("Received RPC message: {Message}", message.Message);
126142
await _manager.HandleClientRpcMessage(message, ct);
127143
}
144+
145+
public async Task BroadcastAsync(ServiceMessage message, CancellationToken ct)
146+
{
147+
// Looping over a ConcurrentDictionary is exception-safe, but any items
148+
// added or removed during the loop may or may not be included.
149+
foreach (var (clientId, client) in _activeClients)
150+
try
151+
{
152+
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
153+
cts.CancelAfter(5 * 1000);
154+
await client.Speaker.SendMessage(message, cts.Token);
155+
}
156+
catch (ObjectDisposedException)
157+
{
158+
// The speaker was likely closed while we were iterating.
159+
}
160+
catch (Exception e)
161+
{
162+
_logger.LogWarning(e, "Failed to send message to client {ClientId}", clientId);
163+
// TODO: this should probably kill the client, but due to the
164+
// async nature of the client handling, calling Dispose
165+
// will not remove the client from the active clients list
166+
}
167+
}
128168
}

‎Vpn.Service/Program.cs

Lines changed: 81 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,91 @@
1-
using Coder.Desktop.Vpn.Service;
21
using Microsoft.Extensions.Configuration;
32
using Microsoft.Extensions.DependencyInjection;
43
using Microsoft.Extensions.Hosting;
54
using Microsoft.Win32;
5+
using Serilog;
66

7-
var builder = Host.CreateApplicationBuilder(args);
7+
namespace Coder.Desktop.Vpn.Service;
88

9-
// Configuration sources
10-
builder.Configuration.Sources.Clear();
11-
(builder.Configuration as IConfigurationBuilder).Add(
12-
new RegistryConfigurationSource(Registry.LocalMachine, @"SOFTWARE\Coder\Coder VPN"));
13-
builder.Configuration.AddEnvironmentVariables("CODER_MANAGER_");
14-
builder.Configuration.AddCommandLine(args);
9+
public static class Program
10+
{
11+
#if DEBUG
12+
private const string serviceName = "Coder Desktop (Debug)";
13+
#else
14+
const string serviceName = "Coder Desktop";
15+
#endif
1516

16-
// Options types (these get registered as IOptions<T> singletons)
17-
builder.Services.AddOptions<ManagerConfig>()
18-
.Bind(builder.Configuration.GetSection("Manager"))
19-
.ValidateDataAnnotations();
17+
private static readonly ILogger MainLogger = Log.ForContext("SourceContext", "Coder.Desktop.Vpn.Service.Program");
2018

21-
// Singletons
22-
builder.Services.AddSingleton<IDownloader, Downloader>();
23-
builder.Services.AddSingleton<ITunnelSupervisor, TunnelSupervisor>();
24-
builder.Services.AddSingleton<IManager, Manager>();
19+
public static async Task<int> Main(string[] args)
20+
{
21+
// Configure Serilog.
22+
Log.Logger = new LoggerConfiguration()
23+
.Enrich.FromLogContext()
24+
// TODO: configurable level
25+
.MinimumLevel.Debug()
26+
.WriteTo.Console(
27+
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}")
28+
// TODO: better location
29+
.WriteTo.File(@"C:\CoderDesktop.log",
30+
outputTemplate:
31+
"{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}")
32+
.CreateLogger();
2533

26-
// Services
27-
builder.Services.AddHostedService<ManagerService>();
28-
builder.Services.AddHostedService<ManagerRpcService>();
34+
try
35+
{
36+
await BuildAndRun(args);
37+
return 0;
38+
}
39+
catch (Exception ex)
40+
{
41+
MainLogger.Fatal(ex, "Host terminated unexpectedly");
42+
return 1;
43+
}
44+
finally
45+
{
46+
await Log.CloseAndFlushAsync();
47+
}
48+
}
2949

30-
builder.Build().Run();
50+
private static async Task BuildAndRun(string[] args)
51+
{
52+
var builder = Host.CreateApplicationBuilder(args);
53+
54+
// Configuration sources
55+
builder.Configuration.Sources.Clear();
56+
(builder.Configuration as IConfigurationBuilder).Add(
57+
new RegistryConfigurationSource(Registry.LocalMachine, @"SOFTWARE\Coder\Coder Desktop"));
58+
builder.Configuration.AddEnvironmentVariables("CODER_MANAGER_");
59+
builder.Configuration.AddCommandLine(args);
60+
61+
// Options types (these get registered as IOptions<T> singletons)
62+
builder.Services.AddOptions<ManagerConfig>()
63+
.Bind(builder.Configuration.GetSection("Manager"))
64+
.ValidateDataAnnotations();
65+
66+
// Logging
67+
builder.Services.AddSerilog();
68+
69+
// Singletons
70+
builder.Services.AddSingleton<IDownloader, Downloader>();
71+
builder.Services.AddSingleton<ITunnelSupervisor, TunnelSupervisor>();
72+
builder.Services.AddSingleton<IManager, Manager>();
73+
74+
// Services
75+
// TODO: is this sound enough to determine if we're a service?
76+
if (!Environment.UserInteractive)
77+
{
78+
MainLogger.Information("Running as a windows service");
79+
builder.Services.AddWindowsService(options => { options.ServiceName = serviceName; });
80+
}
81+
else
82+
{
83+
MainLogger.Information("Running as a console application");
84+
}
85+
86+
builder.Services.AddHostedService<ManagerService>();
87+
builder.Services.AddHostedService<ManagerRpcService>();
88+
89+
await builder.Build().RunAsync();
90+
}
91+
}

‎Vpn.Service/Rebuild-Service.ps1

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
& $PSScriptRoot/Stop-Service.ps1
2+
dotnet build -c Debug ./Vpn.Service.csproj
3+
& $PSScriptRoot/Restart-Service.ps1

‎Vpn.Service/RegistryConfigurationSource.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ public class RegistryConfigurationSource : IConfigurationSource
88
private readonly RegistryKey _root;
99
private readonly string _subKeyName;
1010

11+
// ReSharper disable once ConvertToPrimaryConstructor
1112
public RegistryConfigurationSource(RegistryKey root, string subKeyName)
1213
{
1314
_root = root;
@@ -25,6 +26,7 @@ public class RegistryConfigurationProvider : ConfigurationProvider
2526
private readonly RegistryKey _root;
2627
private readonly string _subKeyName;
2728

29+
// ReSharper disable once ConvertToPrimaryConstructor
2830
public RegistryConfigurationProvider(RegistryKey root, string subKeyName)
2931
{
3032
_root = root;

‎Vpn.Service/Restart-Service.ps1

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
$name = "Coder Desktop (Debug)"
2+
3+
try {
4+
Restart-Service -Name $name -Force
5+
if ((Get-Service -Name $name -ErrorAction Stop).Status -ne "Running") {
6+
throw "Service '$name' is not running"
7+
}
8+
Write-Host "Service '$name' restarted successfully"
9+
} catch {
10+
Write-Host $_ -ForegroundColor Red
11+
Write-Host "Press Return to exit..."
12+
Read-Host
13+
}

‎Vpn.Service/Stop-Service.ps1

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
$name = "Coder Desktop (Debug)"
2+
3+
try {
4+
Stop-Service -Name $name -Force
5+
Write-Host "Service '$name' stopped successfully"
6+
} catch {
7+
Write-Host $_ -ForegroundColor Red
8+
Write-Host "Press Return to exit..."
9+
Read-Host
10+
}

‎Vpn.Service/TunnelSupervisor.cs

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ namespace Coder.Desktop.Vpn.Service;
88

99
public interface ITunnelSupervisor : IAsyncDisposable
1010
{
11+
public bool IsRunning { get; }
12+
1113
/// <summary>
1214
/// Starts the tunnel subprocess with the given executable path. If the subprocess is already running, this method will
1315
/// kill it first.
@@ -27,9 +29,26 @@ public Task StartAsync(string binPath,
2729
/// <summary>
2830
/// Stops the tunnel subprocess. If the subprocess is not running, this method does nothing.
2931
/// </summary>
30-
/// <param name="ct"></param>
31-
/// <returns></returns>
32+
/// <param name="ct">Cancellation token</param>
3233
public Task StopAsync(CancellationToken ct = default);
34+
35+
/// <summary>
36+
/// Sends a message to the tunnel that does not expect a reply.
37+
/// </summary>
38+
/// <param name="message">Message to send</param>
39+
/// <param name="ct">Cancellation token</param>
40+
/// <exception cref="InvalidOperationException">The Speaker is not ready or the tunnel is not running</exception>
41+
public Task SendMessage(ManagerMessage message, CancellationToken ct = default);
42+
43+
/// <summary>
44+
/// Send a message to the tunnel and wait for a reply. The reply will be returned and the callback will not be
45+
/// invoked as long as the reply is received before cancellation or termination.
46+
/// </summary>
47+
/// <param name="message">Message to send - the Rpc field will be overwritten</param>
48+
/// <param name="ct">Cancellation token</param>
49+
/// <returns>Received reply</returns>
50+
/// <exception cref="InvalidOperationException">The Speaker is not ready or the tunnel is not running</exception>
51+
public ValueTask<TunnelMessage> SendRequestAwaitReply(ManagerMessage message, CancellationToken ct = default);
3352
}
3453

3554
/// <summary>
@@ -52,6 +71,8 @@ public TunnelSupervisor(ILogger<TunnelSupervisor> logger)
5271
_logger = logger;
5372
}
5473

74+
public bool IsRunning => _speaker != null;
75+
5576
public async Task StartAsync(string binPath,
5677
Speaker<ManagerMessage, TunnelMessage>.OnReceiveDelegate messageHandler,
5778
Speaker<ManagerMessage, TunnelMessage>.OnErrorDelegate errorHandler,
@@ -76,20 +97,34 @@ public async Task StartAsync(string binPath,
7697
ArgumentList = { "vpn-daemon", "run" },
7798
UseShellExecute = false,
7899
CreateNoWindow = true,
100+
RedirectStandardError = true,
101+
RedirectStandardOutput = true,
79102
},
80103
};
104+
_subprocess.OutputDataReceived += (_, args) =>
105+
{
106+
if (!string.IsNullOrWhiteSpace(args.Data))
107+
_logger.LogDebug("OUT: {Data}", args.Data);
108+
};
109+
_subprocess.ErrorDataReceived += (_, args) =>
110+
{
111+
if (!string.IsNullOrWhiteSpace(args.Data))
112+
_logger.LogDebug("ERR: {Data}", args.Data);
113+
};
81114

82115
// Pass the other end of the pipes to the subprocess and dispose
83116
// the local copies.
84117
_subprocess.StartInfo.Environment.Add("CODER_VPN_DAEMON_RPC_READ_HANDLE",
85118
_outPipe.GetClientHandleAsString());
86119
_subprocess.StartInfo.Environment.Add("CODER_VPN_DAEMON_RPC_WRITE_HANDLE",
87120
_inPipe.GetClientHandleAsString());
88-
_outPipe.DisposeLocalCopyOfClientHandle();
89-
_inPipe.DisposeLocalCopyOfClientHandle();
90121

91122
_logger.LogInformation("StartAsync: starting subprocess");
92123
_subprocess.Start();
124+
_subprocess.BeginOutputReadLine();
125+
_subprocess.BeginErrorReadLine();
126+
_outPipe.DisposeLocalCopyOfClientHandle();
127+
_inPipe.DisposeLocalCopyOfClientHandle();
93128
_logger.LogInformation("StartAsync: subprocess started");
94129

95130
// We don't use the supplied CancellationToken here because we want it to only apply to the startup
@@ -140,6 +175,48 @@ public async Task StopAsync(CancellationToken ct = default)
140175
}
141176
}
142177

178+
public async Task SendMessage(ManagerMessage message, CancellationToken ct = default)
179+
{
180+
if (!await _operationLock.WaitAsync(0, ct))
181+
throw new InvalidOperationException("TunnelSupervisor is not running");
182+
183+
Task task;
184+
try
185+
{
186+
if (_speaker == null)
187+
throw new InvalidOperationException("Speaker is not ready");
188+
task = _speaker.SendMessage(message, ct);
189+
}
190+
finally
191+
{
192+
_operationLock.Release();
193+
}
194+
195+
// Don't await the task while holding the lock.
196+
await task;
197+
}
198+
199+
public async ValueTask<TunnelMessage> SendRequestAwaitReply(ManagerMessage message, CancellationToken ct = default)
200+
{
201+
if (!await _operationLock.WaitAsync(0, ct))
202+
throw new InvalidOperationException("TunnelSupervisor is not running");
203+
204+
ValueTask<TunnelMessage> task;
205+
try
206+
{
207+
if (_speaker == null)
208+
throw new InvalidOperationException("Speaker is not ready");
209+
task = _speaker.SendRequestAwaitReply(message, ct);
210+
}
211+
finally
212+
{
213+
_operationLock.Release();
214+
}
215+
216+
// Don't await the task while holding the lock.
217+
return await task;
218+
}
219+
143220
public async ValueTask DisposeAsync()
144221
{
145222
_cts.Dispose();
@@ -150,13 +227,13 @@ public async ValueTask DisposeAsync()
150227
private async Task OnProcessExited(Task task)
151228
{
152229
if (task.IsFaulted)
230+
_logger.LogError(task.Exception, "OnProcessExited: subprocess task exited with an exception");
231+
if (!await _operationLock.WaitAsync(0))
153232
{
154-
_logger.LogError(task.Exception, "OnProcessExited: subprocess exited with an exception");
233+
_logger.LogInformation("OnProcessExited: could not acquire operation lock to perform cleanup");
155234
return;
156235
}
157236

158-
if (!await _operationLock.WaitAsync(0)) _logger.LogInformation("OnProcessExited: subprocess exited");
159-
160237
try
161238
{
162239
await CleanupAsync();
@@ -170,7 +247,7 @@ private async Task OnProcessExited(Task task)
170247
}
171248

172249
/// <summary>
173-
/// Cleans up the pipes and the subprocess if it's still running. This method should not be called without holding the
250+
/// Cleans up the pipes and the subprocess if it's still running. This method must be called while holding the
174251
/// semaphore.
175252
/// </summary>
176253
private async Task CleanupAsync(CancellationToken ct = default)

‎Vpn.Service/Vpn.Service.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="9.0.1" />
1616
<PackageReference Include="Microsoft.Security.Extensions" Version="1.3.0" />
1717
<PackageReference Include="Semver" Version="3.0.0" />
18+
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0"/>
19+
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
20+
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0"/>
1821
</ItemGroup>
1922

2023
<ItemGroup>

‎Vpn.Service/packages.lock.json

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,37 @@
6868
"Microsoft.Extensions.Primitives": "5.0.1"
6969
}
7070
},
71+
"Serilog.Extensions.Hosting": {
72+
"type": "Direct",
73+
"requested": "[9.0.0, )",
74+
"resolved": "9.0.0",
75+
"contentHash": "u2TRxuxbjvTAldQn7uaAwePkWxTHIqlgjelekBtilAGL5sYyF3+65NWctN4UrwwGLsDC7c3Vz3HnOlu+PcoxXg==",
76+
"dependencies": {
77+
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
78+
"Microsoft.Extensions.Hosting.Abstractions": "9.0.0",
79+
"Microsoft.Extensions.Logging.Abstractions": "9.0.0",
80+
"Serilog": "4.2.0",
81+
"Serilog.Extensions.Logging": "9.0.0"
82+
}
83+
},
84+
"Serilog.Sinks.Console": {
85+
"type": "Direct",
86+
"requested": "[6.0.0, )",
87+
"resolved": "6.0.0",
88+
"contentHash": "fQGWqVMClCP2yEyTXPIinSr5c+CBGUvBybPxjAGcf7ctDhadFhrQw03Mv8rJ07/wR5PDfFjewf2LimvXCDzpbA==",
89+
"dependencies": {
90+
"Serilog": "4.0.0"
91+
}
92+
},
93+
"Serilog.Sinks.File": {
94+
"type": "Direct",
95+
"requested": "[6.0.0, )",
96+
"resolved": "6.0.0",
97+
"contentHash": "lxjg89Y8gJMmFxVkbZ+qDgjl+T4yC5F7WSLTvA+5q0R04tfKVLRL/EHpYoJ/MEQd2EeCKDuylBIVnAYMotmh2A==",
98+
"dependencies": {
99+
"Serilog": "4.0.0"
100+
}
101+
},
71102
"Google.Protobuf": {
72103
"type": "Transitive",
73104
"resolved": "3.29.3",
@@ -327,6 +358,20 @@
327358
"resolved": "9.0.1",
328359
"contentHash": "bHtTesA4lrSGD1ZUaMIx6frU3wyy0vYtTa/hM6gGQu5QNrydObv8T5COiGUWsisflAfmsaFOe9Xvw5NSO99z0g=="
329360
},
361+
"Serilog": {
362+
"type": "Transitive",
363+
"resolved": "4.2.0",
364+
"contentHash": "gmoWVOvKgbME8TYR+gwMf7osROiWAURterc6Rt2dQyX7wtjZYpqFiA/pY6ztjGQKKV62GGCyOcmtP1UKMHgSmA=="
365+
},
366+
"Serilog.Extensions.Logging": {
367+
"type": "Transitive",
368+
"resolved": "9.0.0",
369+
"contentHash": "NwSSYqPJeKNzl5AuXVHpGbr6PkZJFlNa14CdIebVjK3k/76kYj/mz5kiTRNVSsSaxM8kAIa1kpy/qyT9E4npRQ==",
370+
"dependencies": {
371+
"Microsoft.Extensions.Logging": "9.0.0",
372+
"Serilog": "4.2.0"
373+
}
374+
},
330375
"System.Diagnostics.DiagnosticSource": {
331376
"type": "Transitive",
332377
"resolved": "9.0.1",

‎Vpn/Utilities/RaiiSemaphoreSlim.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ public async ValueTask<IDisposable> LockAsync(CancellationToken ct = default)
2424
return new Lock(_semaphore);
2525
}
2626

27+
public async ValueTask<IDisposable?> LockAsync(TimeSpan timeout, CancellationToken ct = default)
28+
{
29+
if (await _semaphore.WaitAsync(timeout, ct)) return null;
30+
31+
return new Lock(_semaphore);
32+
}
33+
2734
private class Lock : IDisposable
2835
{
2936
private readonly SemaphoreSlim _semaphore1;

0 commit comments

Comments
 (0)
Please sign in to comment.