Skip to content
Draft
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
10 changes: 10 additions & 0 deletions Tests.Vpn.Proto/RpcVersionTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ public void IsCompatibleWith()
// 2.1 && 1.1 => null
IsCompatibleWithBothWays(twoOne, new RpcVersion(1, 1), Is.Null);
}

[Test(Description = "Compare versions with IsAtLeast")]
public void IsAtLeast()
{
var oneThree = new RpcVersion(1, 3);
Assert.That(oneThree.IsAtLeast(oneThree), Is.True);
Assert.That(new RpcVersion(1, 4).IsAtLeast(oneThree), Is.True);
Assert.That(new RpcVersion(2, 0).IsAtLeast(oneThree), Is.True);
Assert.That(new RpcVersion(1, 2).IsAtLeast(oneThree), Is.False);
}
}

[TestFixture]
Expand Down
118 changes: 118 additions & 0 deletions Tests.Vpn.Service/ManagerTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using Coder.Desktop.Vpn;
using Coder.Desktop.Vpn.Proto;
using Coder.Desktop.Vpn.Service;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;

namespace Coder.Desktop.Tests.Vpn.Service;

internal class FakeTunnelSupervisor(RpcVersion? negotiatedVersion, Exception? sendException = null)
: ITunnelSupervisor
{
public List<ManagerMessage> SentRequests { get; } = [];
public TaskCompletionSource Sent { get; } = new();

public RpcVersion? NegotiatedVersion => negotiatedVersion;

public ValueTask<TunnelMessage> SendRequestAwaitReply(ManagerMessage message, CancellationToken ct = default)
{
SentRequests.Add(message);
Sent.TrySetResult();
if (sendException != null) throw sendException;
return ValueTask.FromResult(new TunnelMessage { Wake = new WakeResponse() });
}

public Task StartAsync(string binPath, Speaker<ManagerMessage, TunnelMessage>.OnReceiveDelegate messageHandler,
Speaker<ManagerMessage, TunnelMessage>.OnErrorDelegate errorHandler, CancellationToken ct = default)
=> throw new NotImplementedException();

public Task StopAsync(CancellationToken ct = default) => Task.CompletedTask;

public Task SendMessage(ManagerMessage message, CancellationToken ct = default)
=> throw new NotImplementedException();

public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}

internal class FakeSystemResumeMonitor : ISystemResumeMonitor
{
public event EventHandler? Resumed;

public void Raise() => Resumed?.Invoke(this, EventArgs.Empty);

public void Dispose()
{
}
}

internal class FakeManagerRpc : IManagerRpc
{
#pragma warning disable CS0067 // Never raised in tests.
public event IManagerRpc.OnReceiveHandler? OnReceive;
#pragma warning restore CS0067

public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task ExecuteAsync(CancellationToken stoppingToken) => Task.CompletedTask;
public Task BroadcastAsync(ServiceMessage message, CancellationToken ct = default) => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}

internal class FakeDownloader : IDownloader
{
public Task<DownloadTask> StartDownloadAsync(HttpRequestMessage req, string destinationPath,
IDownloadValidator validator, CancellationToken ct = default)
=> throw new NotImplementedException();
}

internal class FakeTelemetryEnricher : ITelemetryEnricher
{
public StartRequest EnrichStartRequest(StartRequest original) => original;
}

[TestFixture]
public class ManagerTest
{
private static Manager NewManager(ITunnelSupervisor tunnelSupervisor, ISystemResumeMonitor? resumeMonitor = null)
=> new(Options.Create(new ManagerConfig()), NullLogger<Manager>.Instance, new FakeDownloader(),
tunnelSupervisor, new FakeManagerRpc(), new FakeTelemetryEnricher(),
resumeMonitor ?? new FakeSystemResumeMonitor());

[Test(Description = "Send a wake request to the tunnel on system resume")]
[CancelAfter(30_000)]
public async Task SendsWakeRequestOnResume(CancellationToken ct)
{
var supervisor = new FakeTunnelSupervisor(new RpcVersion(1, 3));
var monitor = new FakeSystemResumeMonitor();
using var manager = NewManager(supervisor, monitor);

monitor.Raise();
await supervisor.Sent.Task.WaitAsync(ct);

Assert.That(supervisor.SentRequests[0].MsgCase, Is.EqualTo(ManagerMessage.MsgOneofCase.Wake));
}

[TestCase(null, Description = "Tunnel not running")]
[TestCase("1.2", Description = "Version does not support wake")]
[CancelAfter(30_000)]
public async Task SkipsWakeRequestWhenUnsupported(string? version, CancellationToken ct)
{
var supervisor = new FakeTunnelSupervisor(version == null ? null : RpcVersion.Parse(version));
using var manager = NewManager(supervisor);

await manager.SendWakeRequest(ct);

Assert.That(supervisor.SentRequests, Is.Empty);
}

[Test(Description = "Wake request failures are swallowed")]
[CancelAfter(30_000)]
public async Task IgnoresWakeRequestFailure(CancellationToken ct)
{
var supervisor = new FakeTunnelSupervisor(new RpcVersion(1, 3), new InvalidOperationException("not running"));
using var manager = NewManager(supervisor);

await manager.SendWakeRequest(ct);

Assert.That(supervisor.SentRequests, Has.Count.EqualTo(1));
}
}
6 changes: 6 additions & 0 deletions Tests.Vpn.Service/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,11 @@
"Newtonsoft.Json": "13.0.1"
}
},
"Microsoft.Win32.SystemEvents": {
"type": "Transitive",
"resolved": "9.0.4",
"contentHash": "kHgtAkXhNEP8oGuAVe3Q5admxsdMlSdWE2rXcA9FfeGDZJQawPccmZgnOswgW3ugUPSJt7VH+TMQPz65mnhGSQ=="
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.1",
Expand Down Expand Up @@ -513,6 +518,7 @@
"Microsoft.Extensions.Hosting.WindowsServices": "[9.0.4, )",
"Microsoft.Extensions.Options.DataAnnotations": "[9.0.4, )",
"Microsoft.Security.Extensions": "[1.3.0, )",
"Microsoft.Win32.SystemEvents": "[9.0.4, )",
"Semver": "[3.0.0, )",
"Serilog.Extensions.Hosting": "[9.0.0, )",
"Serilog.Settings.Configuration": "[9.0.0, )",
Expand Down
15 changes: 15 additions & 0 deletions Tests.Vpn/SpeakerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,21 @@ await message.SendReply(new TunnelMessage
Assert.That(reply.Message.Start.Success, Is.True);
}

[Test(Description = "Negotiated version is exposed after a successful handshake")]
[CancelAfter(30_000)]
public async Task NegotiatedVersion(CancellationToken ct)
{
var (stream1, stream2) = BidirectionalPipe.NewInMemory();
await using var speaker1 = new Speaker<ManagerMessage, TunnelMessage>(stream1);
await using var speaker2 = new Speaker<TunnelMessage, ManagerMessage>(stream2);

Assert.That(speaker1.NegotiatedVersion, Is.Null);
await Task.WhenAll(speaker1.StartAsync(ct), speaker2.StartAsync(ct));

Assert.That(speaker1.NegotiatedVersion, Is.EqualTo(RpcVersion.Current));
Assert.That(speaker2.NegotiatedVersion, Is.EqualTo(RpcVersion.Current));
}

[Test(Description = "Encounter a write error during handshake")]
[CancelAfter(30_000)]
public async Task WriteError(CancellationToken ct)
Expand Down
11 changes: 10 additions & 1 deletion Vpn.Proto/RpcVersion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ namespace Coder.Desktop.Vpn.Proto;
/// </summary>
public class RpcVersion
{
public static readonly RpcVersion Current = new(1, 1);
// 1.3 adds WakeRequest and WakeResponse.
public static readonly RpcVersion Current = new(1, 3);

public ulong Major { get; }
public ulong Minor { get; }
Expand Down Expand Up @@ -62,6 +63,14 @@ public override string ToString()
return Minor < other.Minor ? this : other;
}

/// <summary>
/// Returns true if this version is equal to or newer than the other version.
/// </summary>
public bool IsAtLeast(RpcVersion other)
{
return Major > other.Major || (Major == other.Major && Minor >= other.Minor);
}

#region RpcVersion Equality

public static bool operator ==(RpcVersion a, RpcVersion b)
Expand Down
8 changes: 8 additions & 0 deletions Vpn.Proto/vpn.proto
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ message ManagerMessage {
NetworkSettingsResponse network_settings = 3;
StartRequest start = 4;
StopRequest stop = 5;
WakeRequest wake = 6;
}
}

Expand All @@ -42,6 +43,7 @@ message TunnelMessage {
NetworkSettingsRequest network_settings = 4;
StartResponse start = 5;
StopResponse stop = 6;
WakeResponse wake = 7;
}
}

Expand Down Expand Up @@ -290,3 +292,9 @@ message Status {
// be populated.
PeerUpdate peer_update = 3;
}

// WakeRequest is sent by the manager after the system wakes from sleep. The
// tunnel uses this as a hint to rediscover network paths.
message WakeRequest {}

message WakeResponse {}
51 changes: 50 additions & 1 deletion Vpn.Service/Manager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,17 @@ public interface IManager : IDisposable
/// </summary>
public class Manager : IManager
{
// Minimum tunnel RPC version that supports WakeRequest.
private static readonly RpcVersion WakeMinimumTunnelRpcVersion = new(1, 3);
private static readonly TimeSpan WakeReplyTimeout = TimeSpan.FromSeconds(5);

private readonly ManagerConfig _config;
private readonly IDownloader _downloader;
private readonly ILogger<Manager> _logger;
private readonly ITunnelSupervisor _tunnelSupervisor;
private readonly IManagerRpc _managerRpc;
private readonly ITelemetryEnricher _telemetryEnricher;
private readonly ISystemResumeMonitor _systemResumeMonitor;

private volatile TunnelStatus _status = TunnelStatus.Stopped;

Expand All @@ -47,7 +52,8 @@ public class Manager : IManager

// ReSharper disable once ConvertToPrimaryConstructor
public Manager(IOptions<ManagerConfig> config, ILogger<Manager> logger, IDownloader downloader,
ITunnelSupervisor tunnelSupervisor, IManagerRpc managerRpc, ITelemetryEnricher telemetryEnricher)
ITunnelSupervisor tunnelSupervisor, IManagerRpc managerRpc, ITelemetryEnricher telemetryEnricher,
ISystemResumeMonitor systemResumeMonitor)
{
_config = config.Value;
_logger = logger;
Expand All @@ -56,10 +62,13 @@ public Manager(IOptions<ManagerConfig> config, ILogger<Manager> logger, IDownloa
_managerRpc = managerRpc;
_managerRpc.OnReceive += HandleClientRpcMessage;
_telemetryEnricher = telemetryEnricher;
_systemResumeMonitor = systemResumeMonitor;
_systemResumeMonitor.Resumed += HandleSystemResumed;
}

public void Dispose()
{
_systemResumeMonitor.Resumed -= HandleSystemResumed;
_managerRpc.OnReceive -= HandleClientRpcMessage;
GC.SuppressFinalize(this);
}
Expand All @@ -70,6 +79,46 @@ public async Task StopAsync(CancellationToken ct = default)
await BroadcastStatus(null, ct);
}

private void HandleSystemResumed(object? sender, EventArgs e)
{
// Fire-and-forget is safe: SendWakeRequest logs and swallows all
// failures, and must not block the SystemEvents broadcast thread.
_ = SendWakeRequest();
}

/// <summary>
/// Sends a WakeRequest to the tunnel so it re-discovers network paths after a system resume. Failures are
/// logged and never affect the running tunnel.
/// </summary>
public async Task SendWakeRequest(CancellationToken ct = default)
{
try
{
var version = _tunnelSupervisor.NegotiatedVersion;
if (version is null || !version.IsAtLeast(WakeMinimumTunnelRpcVersion))
{
_logger.LogDebug(
"Skipping wake request, tunnel is not running or version {Version} does not support it", version);
return;
}

_logger.LogInformation("Sending wake request to tunnel after system resume");
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(WakeReplyTimeout);
var reply = await _tunnelSupervisor.SendRequestAwaitReply(new ManagerMessage
{
Wake = new WakeRequest(),
}, cts.Token);
if (reply.MsgCase != TunnelMessage.MsgOneofCase.Wake)
_logger.LogWarning("Tunnel replied to wake request with unexpected message type {MessageType}",
reply.MsgCase);
}
catch (Exception e)
{
_logger.LogWarning(e, "Failed to send wake request to tunnel");
}
}

/// <summary>
/// Processes a message sent from a Client to the ManagerRpcService over the codervpn RPC protocol.
/// </summary>
Expand Down
1 change: 1 addition & 0 deletions Vpn.Service/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ private static async Task BuildAndRun(string[] args)
builder.Services.AddSingleton<IManagerRpc, ManagerRpc>();
builder.Services.AddSingleton<IManager, Manager>();
builder.Services.AddSingleton<ITelemetryEnricher, TelemetryEnricher>();
builder.Services.AddSingleton<ISystemResumeMonitor, SystemResumeMonitor>();

// Services
builder.Services.AddHostedService<ManagerService>();
Expand Down
32 changes: 32 additions & 0 deletions Vpn.Service/SystemResumeMonitor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Microsoft.Win32;

namespace Coder.Desktop.Vpn.Service;

public interface ISystemResumeMonitor : IDisposable
{
/// <summary>
/// Raised when the system resumes from sleep.
/// </summary>
event EventHandler? Resumed;
}

public class SystemResumeMonitor : ISystemResumeMonitor
{
public event EventHandler? Resumed;

public SystemResumeMonitor()
{
SystemEvents.PowerModeChanged += OnPowerModeChanged;
}

public void Dispose()
{
SystemEvents.PowerModeChanged -= OnPowerModeChanged;
GC.SuppressFinalize(this);
}

private void OnPowerModeChanged(object sender, PowerModeChangedEventArgs e)
{
if (e.Mode == PowerModes.Resume) Resumed?.Invoke(this, EventArgs.Empty);
}
}
7 changes: 7 additions & 0 deletions Vpn.Service/TunnelSupervisor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ namespace Coder.Desktop.Vpn.Service;

public interface ITunnelSupervisor : IAsyncDisposable
{
/// <summary>
/// The RPC version negotiated with the running tunnel, or null if the tunnel is not running.
/// </summary>
public RpcVersion? NegotiatedVersion { get; }

/// <summary>
/// Starts the tunnel subprocess with the given executable path. If the subprocess is already running, this method will
/// kill it first.
Expand Down Expand Up @@ -64,6 +69,8 @@ public class TunnelSupervisor : ITunnelSupervisor
private Speaker<ManagerMessage, TunnelMessage>? _speaker;
private Process? _subprocess;

public RpcVersion? NegotiatedVersion => _speaker?.NegotiatedVersion;

// ReSharper disable once ConvertToPrimaryConstructor
public TunnelSupervisor(ILogger<TunnelSupervisor> logger)
{
Expand Down
1 change: 1 addition & 0 deletions Vpn.Service/Vpn.Service.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="9.0.4" />
<PackageReference Include="Microsoft.Security.Extensions" Version="1.3.0" />
<PackageReference Include="Microsoft.Win32.SystemEvents" Version="9.0.4" />
<PackageReference Include="Semver" Version="3.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
Expand Down
Loading
Loading