diff --git a/Tests.Vpn.Proto/RpcVersionTest.cs b/Tests.Vpn.Proto/RpcVersionTest.cs index 3f6e8f2..150da0a 100644 --- a/Tests.Vpn.Proto/RpcVersionTest.cs +++ b/Tests.Vpn.Proto/RpcVersionTest.cs @@ -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] diff --git a/Tests.Vpn.Service/ManagerTest.cs b/Tests.Vpn.Service/ManagerTest.cs new file mode 100644 index 0000000..f003a0b --- /dev/null +++ b/Tests.Vpn.Service/ManagerTest.cs @@ -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 SentRequests { get; } = []; + public TaskCompletionSource Sent { get; } = new(); + + public RpcVersion? NegotiatedVersion => negotiatedVersion; + + public ValueTask 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.OnReceiveDelegate messageHandler, + Speaker.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 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.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)); + } +} diff --git a/Tests.Vpn.Service/packages.lock.json b/Tests.Vpn.Service/packages.lock.json index 08a9b56..e6d0d5a 100644 --- a/Tests.Vpn.Service/packages.lock.json +++ b/Tests.Vpn.Service/packages.lock.json @@ -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", @@ -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, )", diff --git a/Tests.Vpn/SpeakerTest.cs b/Tests.Vpn/SpeakerTest.cs index 51950f7..04966c7 100644 --- a/Tests.Vpn/SpeakerTest.cs +++ b/Tests.Vpn/SpeakerTest.cs @@ -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(stream1); + await using var speaker2 = new Speaker(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) diff --git a/Vpn.Proto/RpcVersion.cs b/Vpn.Proto/RpcVersion.cs index 641a10d..5f00a59 100644 --- a/Vpn.Proto/RpcVersion.cs +++ b/Vpn.Proto/RpcVersion.cs @@ -5,7 +5,8 @@ namespace Coder.Desktop.Vpn.Proto; /// 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; } @@ -62,6 +63,14 @@ public override string ToString() return Minor < other.Minor ? this : other; } + /// + /// Returns true if this version is equal to or newer than the other version. + /// + 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) diff --git a/Vpn.Proto/vpn.proto b/Vpn.Proto/vpn.proto index 11a481c..bd3a8e5 100644 --- a/Vpn.Proto/vpn.proto +++ b/Vpn.Proto/vpn.proto @@ -30,6 +30,7 @@ message ManagerMessage { NetworkSettingsResponse network_settings = 3; StartRequest start = 4; StopRequest stop = 5; + WakeRequest wake = 6; } } @@ -42,6 +43,7 @@ message TunnelMessage { NetworkSettingsRequest network_settings = 4; StartResponse start = 5; StopResponse stop = 6; + WakeResponse wake = 7; } } @@ -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 {} diff --git a/Vpn.Service/Manager.cs b/Vpn.Service/Manager.cs index 027a882..9c3d396 100644 --- a/Vpn.Service/Manager.cs +++ b/Vpn.Service/Manager.cs @@ -26,12 +26,17 @@ public interface IManager : IDisposable /// 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 _logger; private readonly ITunnelSupervisor _tunnelSupervisor; private readonly IManagerRpc _managerRpc; private readonly ITelemetryEnricher _telemetryEnricher; + private readonly ISystemResumeMonitor _systemResumeMonitor; private volatile TunnelStatus _status = TunnelStatus.Stopped; @@ -47,7 +52,8 @@ public class Manager : IManager // ReSharper disable once ConvertToPrimaryConstructor public Manager(IOptions config, ILogger logger, IDownloader downloader, - ITunnelSupervisor tunnelSupervisor, IManagerRpc managerRpc, ITelemetryEnricher telemetryEnricher) + ITunnelSupervisor tunnelSupervisor, IManagerRpc managerRpc, ITelemetryEnricher telemetryEnricher, + ISystemResumeMonitor systemResumeMonitor) { _config = config.Value; _logger = logger; @@ -56,10 +62,13 @@ public Manager(IOptions config, ILogger 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); } @@ -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(); + } + + /// + /// 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. + /// + 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"); + } + } + /// /// Processes a message sent from a Client to the ManagerRpcService over the codervpn RPC protocol. /// diff --git a/Vpn.Service/Program.cs b/Vpn.Service/Program.cs index 094875d..753fb16 100644 --- a/Vpn.Service/Program.cs +++ b/Vpn.Service/Program.cs @@ -81,6 +81,7 @@ private static async Task BuildAndRun(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); // Services builder.Services.AddHostedService(); diff --git a/Vpn.Service/SystemResumeMonitor.cs b/Vpn.Service/SystemResumeMonitor.cs new file mode 100644 index 0000000..365b5b7 --- /dev/null +++ b/Vpn.Service/SystemResumeMonitor.cs @@ -0,0 +1,32 @@ +using Microsoft.Win32; + +namespace Coder.Desktop.Vpn.Service; + +public interface ISystemResumeMonitor : IDisposable +{ + /// + /// Raised when the system resumes from sleep. + /// + 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); + } +} diff --git a/Vpn.Service/TunnelSupervisor.cs b/Vpn.Service/TunnelSupervisor.cs index 7dd6738..16e7e8e 100644 --- a/Vpn.Service/TunnelSupervisor.cs +++ b/Vpn.Service/TunnelSupervisor.cs @@ -10,6 +10,11 @@ namespace Coder.Desktop.Vpn.Service; public interface ITunnelSupervisor : IAsyncDisposable { + /// + /// The RPC version negotiated with the running tunnel, or null if the tunnel is not running. + /// + public RpcVersion? NegotiatedVersion { get; } + /// /// Starts the tunnel subprocess with the given executable path. If the subprocess is already running, this method will /// kill it first. @@ -64,6 +69,8 @@ public class TunnelSupervisor : ITunnelSupervisor private Speaker? _speaker; private Process? _subprocess; + public RpcVersion? NegotiatedVersion => _speaker?.NegotiatedVersion; + // ReSharper disable once ConvertToPrimaryConstructor public TunnelSupervisor(ILogger logger) { diff --git a/Vpn.Service/Vpn.Service.csproj b/Vpn.Service/Vpn.Service.csproj index aaed3cc..05a0ef6 100644 --- a/Vpn.Service/Vpn.Service.csproj +++ b/Vpn.Service/Vpn.Service.csproj @@ -29,6 +29,7 @@ + diff --git a/Vpn.Service/packages.lock.json b/Vpn.Service/packages.lock.json index 09c7b76..7e541d8 100644 --- a/Vpn.Service/packages.lock.json +++ b/Vpn.Service/packages.lock.json @@ -59,6 +59,12 @@ "resolved": "1.3.0", "contentHash": "xK8WFEo5WMUE8DI8W+GjhRwpVcPrxc4DyTjfxh39+yOyhAtC5TBHDlFEJks5toNZHsUeUuiWELIX25oTWOKPBw==" }, + "Microsoft.Win32.SystemEvents": { + "type": "Direct", + "requested": "[9.0.4, )", + "resolved": "9.0.4", + "contentHash": "kHgtAkXhNEP8oGuAVe3Q5admxsdMlSdWE2rXcA9FfeGDZJQawPccmZgnOswgW3ugUPSJt7VH+TMQPz65mnhGSQ==" + }, "Semver": { "type": "Direct", "requested": "[3.0.0, )", diff --git a/Vpn/Speaker.cs b/Vpn/Speaker.cs index 37ec554..1864abf 100644 --- a/Vpn/Speaker.cs +++ b/Vpn/Speaker.cs @@ -83,6 +83,11 @@ public class Speaker : IAsyncDisposable /// public event OnReceiveDelegate? Receive; + /// + /// The RPC version negotiated during the handshake. Null until StartAsync completes. + /// + public RpcVersion? NegotiatedVersion { get; private set; } + private readonly Stream _conn; // _cts is cancelled when Dispose is called and will cause all ongoing I/O @@ -157,8 +162,10 @@ private async Task PerformHandshake(CancellationToken ct = default) if (header.Role != expectedRole) throw new ArgumentException($"Expected peer role '{expectedRole}' but got '{header.Role}'"); - if (header.VersionList.IsCompatibleWith(RpcVersionList.Current) is null) + var negotiatedVersion = header.VersionList.IsCompatibleWith(RpcVersionList.Current); + if (negotiatedVersion is null) throw new RpcVersionCompatibilityException(RpcVersionList.Current, header.VersionList); + NegotiatedVersion = negotiatedVersion; } private async Task WriteHeader(CancellationToken ct = default)