From 47c7fdb9a25d3fe9d0379ce471381fdf0b86fc8c Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Fri, 7 Feb 2025 23:06:24 +1100 Subject: [PATCH] feat: add agent status to tray app - Adds agent and stopped workspace statuses to the tray app - The Vpn.Service.Manager now tracks the current list of workspaces and agents from the tunnel - Deletes remnants of the old Package - Moves App to be completely unpackaged --- .gitignore | 2 + App/App.csproj | 40 +-- App/App.xaml.cs | 11 +- App/Models/RpcModel.cs | 9 +- App/Properties/launchSettings.json | 3 - App/Services/CredentialManager.cs | 10 +- App/Services/RpcController.cs | 56 +++- App/ViewModels/TrayWindowViewModel.cs | 123 +++++---- App/Views/Pages/TrayWindowMainPage.xaml.cs | 1 + App/Views/TrayWindow.xaml.cs | 10 +- App/packages.lock.json | 11 +- Package/Package.appxmanifest | 52 ---- Package/Package.wapproj | 67 ----- Publish-Alpha.ps1 | 140 ++++++++++ Vpn.Proto/vpn.proto | 289 +++++++++++---------- Vpn.Service/Manager.cs | 203 ++++++++++++--- Vpn.Service/ManagerRpc.cs | 193 ++++++++++++++ Vpn.Service/ManagerRpcService.cs | 156 +---------- Vpn.Service/Program.cs | 8 +- Vpn.Service/TunnelSupervisor.cs | 15 +- 20 files changed, 839 insertions(+), 560 deletions(-) delete mode 100644 Package/Package.appxmanifest delete mode 100644 Package/Package.wapproj create mode 100644 Publish-Alpha.ps1 create mode 100644 Vpn.Service/ManagerRpc.cs diff --git a/.gitignore b/.gitignore index d378f88..4ea0881 100644 --- a/.gitignore +++ b/.gitignore @@ -403,3 +403,5 @@ FodyWeavers.xsd .idea/**/shelf publish +WindowsAppRuntimeInstall-x64.exe +wintun.dll diff --git a/App/App.csproj b/App/App.csproj index 2adf3f7..f6e3c0d 100644 --- a/App/App.csproj +++ b/App/App.csproj @@ -10,18 +10,13 @@ Properties\PublishProfiles\win-$(Platform).pubxml true enable - true + false + None true preview - - - Designer - - - @@ -40,43 +35,12 @@ - - - - - - - - - true - - - - - - False - False - True - diff --git a/App/App.xaml.cs b/App/App.xaml.cs index 29e775d..cc76c1a 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -13,7 +13,6 @@ public partial class App : Application { private readonly IServiceProvider _services; private TrayWindow? _trayWindow; - private readonly bool _handleClosedEvents = true; public App() { @@ -21,6 +20,7 @@ public App() services.AddSingleton(); services.AddSingleton(); + // TrayWindow pages and view models services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -43,14 +43,11 @@ public App() protected override void OnLaunched(LaunchActivatedEventArgs args) { _trayWindow = _services.GetRequiredService(); + // Just hide the window rather than closing it. _trayWindow.Closed += (sender, args) => { - // TODO: wire up HandleClosedEvents properly - if (_handleClosedEvents) - { - args.Handled = true; - _trayWindow.AppWindow.Hide(); - } + args.Handled = true; + _trayWindow.AppWindow.Hide(); }; } } diff --git a/App/Models/RpcModel.cs b/App/Models/RpcModel.cs index 074578f..3272742 100644 --- a/App/Models/RpcModel.cs +++ b/App/Models/RpcModel.cs @@ -1,4 +1,6 @@ using System.Collections.Generic; +using System.Linq; +using Coder.Desktop.Vpn.Proto; namespace Coder.Desktop.App.Models; @@ -23,7 +25,9 @@ public class RpcModel public VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Stopped; - public List Agents { get; set; } = []; + public List Workspaces { get; set; } = []; + + public List Agents { get; set; } = []; public RpcModel Clone() { @@ -31,7 +35,8 @@ public RpcModel Clone() { RpcLifecycle = RpcLifecycle, VpnLifecycle = VpnLifecycle, - Agents = Agents, + Workspaces = Workspaces.ToList(), + Agents = Agents.ToList(), }; } } diff --git a/App/Properties/launchSettings.json b/App/Properties/launchSettings.json index 4a35a11..ce91823 100644 --- a/App/Properties/launchSettings.json +++ b/App/Properties/launchSettings.json @@ -1,8 +1,5 @@ { "profiles": { - "App (Package)": { - "commandName": "MsixPackage" - }, "App (Unpackaged)": { "commandName": "Project" } diff --git a/App/Services/CredentialManager.cs b/App/Services/CredentialManager.cs index ad2f366..35e3ee6 100644 --- a/App/Services/CredentialManager.cs +++ b/App/Services/CredentialManager.cs @@ -6,7 +6,6 @@ using System.Threading.Tasks; using Coder.Desktop.App.Models; using Coder.Desktop.Vpn.Utilities; -using CoderSdk; namespace Coder.Desktop.App.Services; @@ -64,18 +63,23 @@ public async Task SetCredentials(string coderUrl, string apiToken, CancellationT if (apiToken.Length != 33) throw new ArgumentOutOfRangeException(nameof(apiToken), "API token must be 33 characters long"); + // TODO: this code seems to hang? + /* try { + var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(5)); var sdkClient = new CoderApiClient(uri); // TODO: we should probably perform a version check here too, // rather than letting the service do it on Start - _ = await sdkClient.GetBuildInfo(ct); - _ = await sdkClient.GetUser(User.Me, ct); + _ = await sdkClient.GetBuildInfo(cts.Token); + _ = await sdkClient.GetUser(User.Me, cts.Token); } catch (Exception e) { throw new InvalidOperationException("Could not connect to or verify Coder server", e); } + */ WriteCredentials(new RawCredentials { diff --git a/App/Services/RpcController.cs b/App/Services/RpcController.cs index 70ae8f3..01484c7 100644 --- a/App/Services/RpcController.cs +++ b/App/Services/RpcController.cs @@ -96,6 +96,7 @@ public async Task Reconnect(CancellationToken ct = default) { state.RpcLifecycle = RpcLifecycle.Connecting; state.VpnLifecycle = VpnLifecycle.Stopped; + state.Workspaces.Clear(); state.Agents.Clear(); }); @@ -126,6 +127,7 @@ public async Task Reconnect(CancellationToken ct = default) { state.RpcLifecycle = RpcLifecycle.Disconnected; state.VpnLifecycle = VpnLifecycle.Stopped; + state.Workspaces.Clear(); state.Agents.Clear(); }); throw new RpcOperationException("Failed to reconnect to the RPC server", e); @@ -134,10 +136,18 @@ public async Task Reconnect(CancellationToken ct = default) MutateState(state => { state.RpcLifecycle = RpcLifecycle.Connected; - // TODO: fetch current state - state.VpnLifecycle = VpnLifecycle.Stopped; + state.VpnLifecycle = VpnLifecycle.Stopping; // prevents clicking the toggle + state.Workspaces.Clear(); state.Agents.Clear(); }); + + var statusReply = await _speaker.SendRequestAwaitReply(new ClientMessage + { + Status = new StatusRequest(), + }, ct); + if (statusReply.MsgCase != ServiceMessage.MsgOneofCase.Status) + throw new InvalidOperationException($"Unexpected reply message type: {statusReply.MsgCase}"); + ApplyStatusUpdate(statusReply.Status); } public async Task StartVpn(CancellationToken ct = default) @@ -234,9 +244,40 @@ private async Task AcquireOperationLockNowAsync() return locker; } + private void ApplyStatusUpdate(Status status) + { + MutateState(state => + { + state.VpnLifecycle = status.Lifecycle switch + { + Status.Types.Lifecycle.Unknown => VpnLifecycle.Stopping, // disables the switch + Status.Types.Lifecycle.Starting => VpnLifecycle.Starting, + Status.Types.Lifecycle.Started => VpnLifecycle.Started, + Status.Types.Lifecycle.Stopping => VpnLifecycle.Stopping, + Status.Types.Lifecycle.Stopped => VpnLifecycle.Stopped, + _ => VpnLifecycle.Stopped, + }; + state.Workspaces.Clear(); + state.Workspaces.AddRange(status.PeerUpdate.UpsertedWorkspaces); + state.Agents.Clear(); + state.Agents.AddRange(status.PeerUpdate.UpsertedAgents); + }); + } + private void SpeakerOnReceive(ReplyableRpcMessage message) { - // TODO: this + switch (message.Message.MsgCase) + { + case ServiceMessage.MsgOneofCase.Status: + ApplyStatusUpdate(message.Message.Status); + break; + case ServiceMessage.MsgOneofCase.Start: + case ServiceMessage.MsgOneofCase.Stop: + case ServiceMessage.MsgOneofCase.None: + default: + // TODO: log unexpected message + break; + } } private async Task DisposeSpeaker() @@ -251,7 +292,14 @@ private async Task DisposeSpeaker() private void SpeakerOnError(Exception e) { Debug.WriteLine($"Error: {e}"); - Reconnect(CancellationToken.None).Wait(); + try + { + Reconnect(CancellationToken.None).Wait(); + } + catch + { + // best effort to immediately reconnect + } } private void AssertRpcConnected() diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index a32f24d..576bf72 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -1,10 +1,12 @@ +using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Linq; using Coder.Desktop.App.Models; using Coder.Desktop.App.Services; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Google.Protobuf; +using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -17,6 +19,8 @@ public partial class TrayWindowViewModel : ObservableObject private readonly IRpcController _rpcController; private readonly ICredentialManager _credentialManager; + private DispatcherQueue? _dispatcherQueue; + [ObservableProperty] public partial VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Stopping; // to prevent interaction until we get the real state @@ -32,7 +36,7 @@ public partial class TrayWindowViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(NoAgents))] [NotifyPropertyChangedFor(nameof(AgentOverflow))] [NotifyPropertyChangedFor(nameof(VisibleAgents))] - public partial ObservableCollection Agents { get; set; } = []; + public partial List Agents { get; set; } = []; public bool NoAgents => Agents.Count == 0; @@ -51,6 +55,11 @@ public TrayWindowViewModel(IRpcController rpcController, ICredentialManager cred { _rpcController = rpcController; _credentialManager = credentialManager; + } + + public void Initialize(DispatcherQueue dispatcherQueue) + { + _dispatcherQueue = dispatcherQueue; _rpcController.StateChanged += (_, rpcModel) => UpdateFromRpcModel(rpcModel); UpdateFromRpcModel(_rpcController.GetState()); @@ -61,6 +70,14 @@ public TrayWindowViewModel(IRpcController rpcController, ICredentialManager cred private void UpdateFromRpcModel(RpcModel rpcModel) { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => UpdateFromRpcModel(rpcModel)); + return; + } + // As a failsafe, if RPC is disconnected we disable the switch. The // Window should not show the current Page if the RPC is disconnected. if (rpcModel.RpcLifecycle is RpcLifecycle.Disconnected) @@ -73,52 +90,67 @@ private void UpdateFromRpcModel(RpcModel rpcModel) VpnLifecycle = rpcModel.VpnLifecycle; VpnSwitchOn = rpcModel.VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started; - // TODO: convert from RpcModel once we send agent data - Agents = - [ - new AgentViewModel - { - Hostname = "pog", - HostnameSuffix = ".coder", - ConnectionStatus = AgentConnectionStatus.Green, - DashboardUrl = "https://dev.coder.com/@dean/pog", - }, - new AgentViewModel - { - Hostname = "pog2", - HostnameSuffix = ".coder", - ConnectionStatus = AgentConnectionStatus.Gray, - DashboardUrl = "https://dev.coder.com/@dean/pog2", - }, - new AgentViewModel - { - Hostname = "pog3", - HostnameSuffix = ".coder", - ConnectionStatus = AgentConnectionStatus.Red, - DashboardUrl = "https://dev.coder.com/@dean/pog3", - }, - new AgentViewModel + + // Add every known agent. + HashSet workspacesWithAgents = []; + List agents = []; + foreach (var agent in rpcModel.Agents) + { + // Find the FQDN with the least amount of dots and split it into + // prefix and suffix. + var fqdn = agent.Fqdn + .Select(a => a.Trim('.')) + .Where(a => !string.IsNullOrWhiteSpace(a)) + .Aggregate((a, b) => a.Count(c => c == '.') < b.Count(c => c == '.') ? a : b); + if (string.IsNullOrWhiteSpace(fqdn)) + continue; + + var fqdnPrefix = fqdn; + var fqdnSuffix = ""; + if (fqdn.Contains('.')) { - Hostname = "pog4", - HostnameSuffix = ".coder", - ConnectionStatus = AgentConnectionStatus.Red, - DashboardUrl = "https://dev.coder.com/@dean/pog4", - }, - new AgentViewModel + fqdnPrefix = fqdn[..fqdn.LastIndexOf('.')]; + fqdnSuffix = fqdn[fqdn.LastIndexOf('.')..]; + } + + var lastHandshakeAgo = DateTime.UtcNow.Subtract(agent.LastHandshake.ToDateTime()); + workspacesWithAgents.Add(agent.WorkspaceId); + agents.Add(new AgentViewModel { - Hostname = "pog5", - HostnameSuffix = ".coder", - ConnectionStatus = AgentConnectionStatus.Red, - DashboardUrl = "https://dev.coder.com/@dean/pog5", - }, - new AgentViewModel + Hostname = fqdnPrefix, + HostnameSuffix = fqdnSuffix, + ConnectionStatus = lastHandshakeAgo < TimeSpan.FromMinutes(5) + ? AgentConnectionStatus.Green + : AgentConnectionStatus.Red, + // TODO: we don't actually have any way of crafting a dashboard + // URL without the owner's username + DashboardUrl = "https://coder.com", + }); + } + + // For every workspace that doesn't have an agent, add a dummy agent. + foreach (var workspace in rpcModel.Workspaces.Where(w => !workspacesWithAgents.Contains(w.Id))) + { + agents.Add(new AgentViewModel { - Hostname = "pog6", + // We just assume that it's a single-agent workspace. + Hostname = workspace.Name, HostnameSuffix = ".coder", - ConnectionStatus = AgentConnectionStatus.Red, - DashboardUrl = "https://dev.coder.com/@dean/pog6", - }, - ]; + ConnectionStatus = AgentConnectionStatus.Gray, + // TODO: we don't actually have any way of crafting a dashboard + // URL without the owner's username + DashboardUrl = "https://coder.com", + }); + } + + // Sort by status green, red, gray, then by hostname. + agents.Sort((a, b) => + { + if (a.ConnectionStatus != b.ConnectionStatus) + return a.ConnectionStatus.CompareTo(b.ConnectionStatus); + return string.Compare(a.FullHostname, b.FullHostname, StringComparison.Ordinal); + }); + Agents = agents; if (Agents.Count < MaxAgents) ShowAllAgents = false; } @@ -162,7 +194,8 @@ public void ToggleShowAllAgents() [RelayCommand] public void SignOut() { - // TODO: this should either be blocked until the VPN is stopped or it should stop the VPN + if (VpnLifecycle is not VpnLifecycle.Stopped) + return; _credentialManager.ClearCredentials(); } } diff --git a/App/Views/Pages/TrayWindowMainPage.xaml.cs b/App/Views/Pages/TrayWindowMainPage.xaml.cs index 913de6b..5911092 100644 --- a/App/Views/Pages/TrayWindowMainPage.xaml.cs +++ b/App/Views/Pages/TrayWindowMainPage.xaml.cs @@ -14,6 +14,7 @@ public TrayWindowMainPage(TrayWindowViewModel viewModel) { InitializeComponent(); ViewModel = viewModel; + ViewModel.Initialize(DispatcherQueue); } // HACK: using XAML to populate the text Runs results in an additional diff --git a/App/Views/TrayWindow.xaml.cs b/App/Views/TrayWindow.xaml.cs index 0a1744d..a65e162 100644 --- a/App/Views/TrayWindow.xaml.cs +++ b/App/Views/TrayWindow.xaml.cs @@ -108,6 +108,14 @@ private void CredentialManager_CredentialsChanged(object? _, CredentialModel mod // trigger when the Page's content changes. public void SetRootFrame(Page page) { + if (!DispatcherQueue.HasThreadAccess) + { + DispatcherQueue.TryEnqueue(() => SetRootFrame(page)); + return; + } + + if (ReferenceEquals(page, RootFrame.Content)) return; + if (page.Content is not FrameworkElement newElement) throw new Exception("Failed to get Page.Content as FrameworkElement on RootFrame navigation"); newElement.SizeChanged += Content_SizeChanged; @@ -239,7 +247,7 @@ private void Tray_Open() [RelayCommand] private void Tray_Exit() { - // TODO: implement exit + Application.Current.Exit(); } public class NativeApi diff --git a/App/packages.lock.json b/App/packages.lock.json index 66a2a84..ca5e679 100644 --- a/App/packages.lock.json +++ b/App/packages.lock.json @@ -35,12 +35,6 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1" } }, - "Microsoft.Windows.SDK.BuildTools": { - "type": "Direct", - "requested": "[10.0.26100.1742, )", - "resolved": "10.0.26100.1742", - "contentHash": "ypcHjr4KEi6xQhgClnbXoANHcyyX/QsC4Rky4igs6M4GiDa+weegPo8JuV/VMxqrZCV4zlqDsp2krgkN7ReAAg==" - }, "Microsoft.WindowsAppSDK": { "type": "Direct", "requested": "[1.6.250108002, )", @@ -87,6 +81,11 @@ "resolved": "9.0.0", "contentHash": "z8FfGIaoeALdD+KF44A2uP8PZIQQtDGiXsOLuN8nohbKhkyKt7zGaZb+fKiCxTuBqG22Q7myIAioSWaIcOOrOw==" }, + "Microsoft.Windows.SDK.BuildTools": { + "type": "Transitive", + "resolved": "10.0.22621.756", + "contentHash": "7ZL2sFSioYm1Ry067Kw1hg0SCcW5kuVezC2SwjGbcPE61Nn+gTbH86T73G3LcEOVj0S3IZzNuE/29gZvOLS7VA==" + }, "System.Collections.Immutable": { "type": "Transitive", "resolved": "9.0.0", diff --git a/Package/Package.appxmanifest b/Package/Package.appxmanifest deleted file mode 100644 index 679c072..0000000 --- a/Package/Package.appxmanifest +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - App (Package) - dean - Images\StoreLogo.png - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Package/Package.wapproj b/Package/Package.wapproj deleted file mode 100644 index 76d48c6..0000000 --- a/Package/Package.wapproj +++ /dev/null @@ -1,67 +0,0 @@ - - - - 15.0 - - - - Debug - x86 - - - Release - x86 - - - Debug - x64 - - - Release - x64 - - - Debug - ARM64 - - - Release - ARM64 - - - - $(MSBuildExtensionsPath)\Microsoft\DesktopBridge\ - App\ - - - - c184988d-56e0-451f-b6a1-e5fe0405c80b - 10.0.22621.0 - 10.0.17763.0 - net8.0-windows$(TargetPlatformVersion);$(AssetTargetFallback) - en-US - false - ..\App\App.csproj - - - - Designer - - - - - - - - - - True - Properties\PublishProfiles\win-$(Platform).pubxml - - - - - - - - diff --git a/Publish-Alpha.ps1 b/Publish-Alpha.ps1 new file mode 100644 index 0000000..79032b3 --- /dev/null +++ b/Publish-Alpha.ps1 @@ -0,0 +1,140 @@ +# CD to the directory of this PS script +Push-Location $PSScriptRoot + +# Create a publish directory +$publishDir = Join-Path $PSScriptRoot "publish" +if (Test-Path $publishDir) { + # prompt the user to confirm the deletion + $confirm = Read-Host "The directory $publishDir already exists. Do you want to delete it? (y/n)" + if ($confirm -eq "y") { + Remove-Item -Recurse -Force $publishDir + } else { + Write-Host "Aborting..." + exit + } +} +New-Item -ItemType Directory -Path $publishDir + +# Build in release mode +dotnet.exe clean +dotnet.exe publish .\Vpn.Service\Vpn.Service.csproj -c Release -a x64 -o $publishDir\service +$msbuildBinary = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe +& $msbuildBinary .\App\App.csproj /p:Configuration=Release /p:Platform=x64 /p:OutputPath=..\publish\app /p:GenerateAppxPackageOnBuild=true + +$scriptsDir = Join-Path $publishDir "scripts" +New-Item -ItemType Directory -Path $scriptsDir + +# Download the 1.6.250108002 redistributable zip from here and drop the x64 +# version in the root of the repo: +# https://learn.microsoft.com/en-us/windows/apps/windows-app-sdk/downloads +$windowsAppSdkInstaller = Join-Path $PSScriptRoot "WindowsAppRuntimeInstall-x64.exe" +Copy-Item $windowsAppSdkInstaller $scriptsDir + +# Acquire wintun.dll and put it in the root of the repo. +$wintunDll = Join-Path $PSScriptRoot "wintun.dll" +Copy-Item $wintunDll $scriptsDir + +# Add a PS1 script for installing the service +$installScript = Join-Path $scriptsDir "Install.ps1" +$installScriptContent = @" +try { + # Install Windows App SDK + `$installerPath = Join-Path `$PSScriptRoot "WindowsAppRuntimeInstall-x64.exe" + Start-Process `$installerPath -ArgumentList "/silent" -Wait + + # Install wintun.dll + `$wintunPath = Join-Path `$PSScriptRoot "wintun.dll" + Copy-Item `$wintunPath "C:\wintun.dll" + + # Install and start the service + `$name = "Coder Desktop (Debug)" + `$binaryPath = Join-Path `$PSScriptRoot "..\service\Vpn.Service.exe" | Resolve-Path + New-Service -Name `$name -BinaryPathName `$binaryPath -StartupType Automatic + Start-Service -Name `$name +} catch { + Write-Host "" + Write-Host -Foreground Red "Error: $_" +} finally { + Write-Host "" + Write-Host "Press Return to exit..." + Read-Host +} +"@ +Set-Content -Path $installScript -Value $installScriptContent + +# Add a batch script for running the install script +$installBatch = Join-Path $publishDir "Install.bat" +$installBatchContent = @" +@echo off +powershell -Command "Start-Process powershell -ArgumentList '-NoProfile -ExecutionPolicy Bypass -File \"%~dp0scripts\Install.ps1\"' -Verb RunAs" +"@ +Set-Content -Path $installBatch -Value $installBatchContent + +# Add a PS1 script for uninstalling the service +$uninstallScript = Join-Path $scriptsDir "Uninstall.ps1" +$uninstallScriptContent = @" +try { + # Uninstall the service + `$name = "Coder Desktop (Debug)" + Stop-Service -Name `$name + sc.exe delete `$name + + # Delete wintun.dll + Remove-Item "C:\wintun.dll" + + # Maybe delete C:\coder-vpn.exe and C:\CoderDesktop.log + Remove-Item "C:\coder-vpn.exe" -ErrorAction SilentlyContinue + Remove-Item "C:\CoderDesktop.log" -ErrorAction SilentlyContinue +} catch { + Write-Host "" + Write-Host -Foreground Red "Error: $_" +} finally { + Write-Host "" + Write-Host "Press Return to exit..." + Read-Host +} +"@ +Set-Content -Path $uninstallScript -Value $uninstallScriptContent + +# Add a batch script for running the uninstall script +$uninstallBatch = Join-Path $publishDir "Uninstall.bat" +$uninstallBatchContent = @" +@echo off +powershell -Command "Start-Process powershell -ArgumentList '-NoProfile -ExecutionPolicy Bypass -File \"%~dp0scripts\Uninstall.ps1\"' -Verb RunAs" +"@ +Set-Content -Path $uninstallBatch -Value $uninstallBatchContent + +# Add a PS1 script for starting the app +$startAppScript = Join-Path $publishDir "StartTrayApp.bat" +$startAppScriptContent = @" +@echo off +start /B app\App.exe +"@ +Set-Content -Path $startAppScript -Value $startAppScriptContent + +# Write README.md +$readme = Join-Path $publishDir "README.md" +$readmeContent = @" +# Coder Desktop for Windows + +## Install +1. Install the service by double clicking `Install.bat`. +2. Start the app by double clicking `StartTrayApp.bat`. +3. The tray app should be available in the system tray. + +## Uninstall +1. Close the tray app by right clicking the icon in the system tray and + selecting "Exit". +2. Uninstall the service by double clicking `Uninstall.bat`. + +## Notes +- During install and uninstall a User Account Control popup will appear asking + for admin permissions. This is normal. +- During install and uninstall a bunch of console windows will appear and + disappear. You will be asked to click "Return" to close the last one once + it's finished doing its thing. +- The system service will start automatically when the system starts. +- The tray app will not start automatically on startup. You can start it again + by double clicking `StartTrayApp.bat`. +"@ +Set-Content -Path $readme -Value $readmeContent diff --git a/Vpn.Proto/vpn.proto b/Vpn.Proto/vpn.proto index a03978a..6da7a66 100644 --- a/Vpn.Proto/vpn.proto +++ b/Vpn.Proto/vpn.proto @@ -1,4 +1,4 @@ -syntax = "proto3"; +syntax = "proto3"; option go_package = "github.com/coder/coder/v2/vpn"; option csharp_namespace = "Coder.Desktop.Vpn.Proto"; @@ -17,73 +17,75 @@ package vpn; // msg_id which it sets on the request, the responder sets response_to that msg_id on the response // message message RPC { - uint64 msg_id = 1; - uint64 response_to = 2; + uint64 msg_id = 1; + uint64 response_to = 2; } // ManagerMessage is a message from the manager (to the tunnel). message ManagerMessage { - RPC rpc = 1; - oneof msg { - GetPeerUpdate get_peer_update = 2; - NetworkSettingsResponse network_settings = 3; - StartRequest start = 4; - StopRequest stop = 5; - } + RPC rpc = 1; + oneof msg { + GetPeerUpdate get_peer_update = 2; + NetworkSettingsResponse network_settings = 3; + StartRequest start = 4; + StopRequest stop = 5; + } } // TunnelMessage is a message from the tunnel (to the manager). message TunnelMessage { - RPC rpc = 1; - oneof msg { - Log log = 2; - PeerUpdate peer_update = 3; - NetworkSettingsRequest network_settings = 4; - StartResponse start = 5; - StopResponse stop = 6; - } + RPC rpc = 1; + oneof msg { + Log log = 2; + PeerUpdate peer_update = 3; + NetworkSettingsRequest network_settings = 4; + StartResponse start = 5; + StopResponse stop = 6; + } } -// ClientMessage is a message from the client (to the service). +// ClientMessage is a message from the client (to the service). Windows only. message ClientMessage { RPC rpc = 1; oneof msg { StartRequest start = 2; StopRequest stop = 3; + StatusRequest status = 4; } } -// ServiceMessage is a message from the service (to the client). +// ServiceMessage is a message from the service (to the client). Windows only. message ServiceMessage { RPC rpc = 1; oneof msg { StartResponse start = 2; StopResponse stop = 3; + Status status = 4; // either in reply to a StatusRequest or broadcasted } } // Log is a log message generated by the tunnel. The manager should log it to the system log. It is // one-way tunnel -> manager with no response. message Log { - enum Level { - // these are designed to match slog levels - DEBUG = 0; - INFO = 1; - WARN = 2; - ERROR = 3; - CRITICAL = 4; - FATAL = 5; - } - Level level = 1; - - string message = 2; - repeated string logger_names = 3; - - message Field { - string name = 1; - string value = 2; - } - repeated Field fields = 4; + enum Level { + // these are designed to match slog levels + DEBUG = 0; + INFO = 1; + WARN = 2; + ERROR = 3; + CRITICAL = 4; + FATAL = 5; + } + Level level = 1; + + string message = 2; + repeated string logger_names = 3; + + message Field { + string name = 1; + string value = 2; + } + repeated Field fields = 4; } // GetPeerUpdate asks for a PeerUpdate with a full set of data. @@ -93,130 +95,153 @@ message GetPeerUpdate {} // response to GetPeerUpdate (which dumps the full set). It is also generated on any changes (not in // response to any request). message PeerUpdate { - repeated Workspace upserted_workspaces = 1; - repeated Agent upserted_agents = 2; - repeated Workspace deleted_workspaces = 3; - repeated Agent deleted_agents = 4; + repeated Workspace upserted_workspaces = 1; + repeated Agent upserted_agents = 2; + repeated Workspace deleted_workspaces = 3; + repeated Agent deleted_agents = 4; } message Workspace { - bytes id = 1; // UUID - string name = 2; - - enum Status { - UNKNOWN = 0; - PENDING = 1; - STARTING = 2; - RUNNING = 3; - STOPPING = 4; - STOPPED = 5; - FAILED = 6; - CANCELING = 7; - CANCELED = 8; - DELETING = 9; - DELETED = 10; - } - Status status = 3; + bytes id = 1; // UUID + string name = 2; + + enum Status { + UNKNOWN = 0; + PENDING = 1; + STARTING = 2; + RUNNING = 3; + STOPPING = 4; + STOPPED = 5; + FAILED = 6; + CANCELING = 7; + CANCELED = 8; + DELETING = 9; + DELETED = 10; + } + Status status = 3; } message Agent { - bytes id = 1; // UUID - string name = 2; - bytes workspace_id = 3; // UUID - repeated string fqdn = 4; - repeated string ip_addrs = 5; - // last_handshake is the primary indicator of whether we are connected to a peer. Zero value or - // anything longer than 5 minutes ago means there is a problem. - google.protobuf.Timestamp last_handshake = 6; + bytes id = 1; // UUID + string name = 2; + bytes workspace_id = 3; // UUID + repeated string fqdn = 4; + repeated string ip_addrs = 5; + // last_handshake is the primary indicator of whether we are connected to a peer. Zero value or + // anything longer than 5 minutes ago means there is a problem. + google.protobuf.Timestamp last_handshake = 6; } // NetworkSettingsRequest is based on // https://developer.apple.com/documentation/networkextension/nepackettunnelnetworksettings for // macOS. It is a request/response message with response NetworkSettingsResponse message NetworkSettingsRequest { - uint32 tunnel_overhead_bytes = 1; - uint32 mtu = 2; - - message DNSSettings { - repeated string servers = 1; - repeated string search_domains = 2; - // domain_name is the primary domain name of the tunnel - string domain_name = 3; - repeated string match_domains = 4; - // match_domains_no_search specifies if the domains in the matchDomains list should not be - // appended to the resolver’s list of search domains. - bool match_domains_no_search = 5; - } - DNSSettings dns_settings = 3; - - string tunnel_remote_address = 4; - - message IPv4Settings { - repeated string addrs = 1; - repeated string subnet_masks = 2; - // router is the next-hop router in dotted-decimal format - string router = 3; - - message IPv4Route { - string destination = 1; - string mask = 2; - // router is the next-hop router in dotted-decimal format - string router = 3; - } - repeated IPv4Route included_routes = 4; - repeated IPv4Route excluded_routes = 5; - } - IPv4Settings ipv4_settings = 5; - - message IPv6Settings { - repeated string addrs = 1; - repeated uint32 prefix_lengths = 2; - - message IPv6Route { - string destination = 1; - uint32 prefix_length = 2; - // router is the address of the next-hop - string router = 3; - } - repeated IPv6Route included_routes = 3; - repeated IPv6Route excluded_routes = 4; - } - IPv6Settings ipv6_settings = 6; + uint32 tunnel_overhead_bytes = 1; + uint32 mtu = 2; + + message DNSSettings { + repeated string servers = 1; + repeated string search_domains = 2; + // domain_name is the primary domain name of the tunnel + string domain_name = 3; + repeated string match_domains = 4; + // match_domains_no_search specifies if the domains in the matchDomains list should not be + // appended to the resolver’s list of search domains. + bool match_domains_no_search = 5; + } + DNSSettings dns_settings = 3; + + string tunnel_remote_address = 4; + + message IPv4Settings { + repeated string addrs = 1; + repeated string subnet_masks = 2; + // router is the next-hop router in dotted-decimal format + string router = 3; + + message IPv4Route { + string destination = 1; + string mask = 2; + // router is the next-hop router in dotted-decimal format + string router = 3; + } + repeated IPv4Route included_routes = 4; + repeated IPv4Route excluded_routes = 5; + } + IPv4Settings ipv4_settings = 5; + + message IPv6Settings { + repeated string addrs = 1; + repeated uint32 prefix_lengths = 2; + + message IPv6Route { + string destination = 1; + uint32 prefix_length = 2; + // router is the address of the next-hop + string router = 3; + } + repeated IPv6Route included_routes = 3; + repeated IPv6Route excluded_routes = 4; + } + IPv6Settings ipv6_settings = 6; } // NetworkSettingsResponse is the response from the manager to the tunnel for a // NetworkSettingsRequest message NetworkSettingsResponse { - bool success = 1; - string error_message = 2; + bool success = 1; + string error_message = 2; } // StartRequest is a request from the manager to start the tunnel. The tunnel replies with a // StartResponse. message StartRequest { - int32 tunnel_file_descriptor = 1; - string coder_url = 2; - string api_token = 3; - // Additional HTTP headers added to all requests - message Header { - string name = 1; - string value = 2; - } - repeated Header headers = 4; + int32 tunnel_file_descriptor = 1; + string coder_url = 2; + string api_token = 3; + // Additional HTTP headers added to all requests + message Header { + string name = 1; + string value = 2; + } + repeated Header headers = 4; } message StartResponse { - bool success = 1; - string error_message = 2; + bool success = 1; + string error_message = 2; } -// StopRequest is a request from the manager to stop the tunnel. The tunnel replies with a +// StopRequest is a request to stop the tunnel. The tunnel replies with a // StopResponse. message StopRequest {} // StopResponse is a response to stopping the tunnel. After sending this response, the tunnel closes // its side of the bidirectional stream for writing. message StopResponse { - bool success = 1; - string error_message = 2; + bool success = 1; + string error_message = 2; +} + +// StatusRequest is a request to get the status of the tunnel. The manager +// replies with a Status. +message StatusRequest {} + +// Status is sent in response to a StatusRequest or broadcasted to all clients +// when the status changes. +message Status { + enum Lifecycle { + UNKNOWN = 0; + STARTING = 1; + STARTED = 2; + STOPPING = 3; + STOPPED = 4; + } + Lifecycle lifecycle = 1; + string error_message = 2; + + // This will be a FULL update with all workspaces and agents, so clients + // should replace their current peer state. Only the Upserted fields will + // be populated. + PeerUpdate peer_update = 3; } diff --git a/Vpn.Service/Manager.cs b/Vpn.Service/Manager.cs index 2a7fcca..6ed7b82 100644 --- a/Vpn.Service/Manager.cs +++ b/Vpn.Service/Manager.cs @@ -8,11 +8,16 @@ namespace Coder.Desktop.Vpn.Service; -public interface IManager : IDisposable +public enum TunnelStatus { - public Task HandleClientRpcMessage(ReplyableRpcMessage message, - CancellationToken ct = default); + Starting, + Started, + Stopping, + Stopped, +} +public interface IManager : IDisposable +{ public Task StopAsync(CancellationToken ct = default); } @@ -28,6 +33,9 @@ public class Manager : IManager private readonly IDownloader _downloader; private readonly ILogger _logger; private readonly ITunnelSupervisor _tunnelSupervisor; + private readonly IManagerRpc _managerRpc; + + private volatile TunnelStatus _status = TunnelStatus.Stopped; // TunnelSupervisor already has protections against concurrent operations, // but all the other stuff before starting the tunnel does not. @@ -35,66 +43,84 @@ public class Manager : IManager private SemVersion? _lastServerVersion; private StartRequest? _lastStartRequest; + private readonly RaiiSemaphoreSlim _statusLock = new(1, 1); + private readonly List _trackedWorkspaces = []; + private readonly List _trackedAgents = []; + // ReSharper disable once ConvertToPrimaryConstructor public Manager(IOptions config, ILogger logger, IDownloader downloader, - ITunnelSupervisor tunnelSupervisor) + ITunnelSupervisor tunnelSupervisor, IManagerRpc managerRpc) { _config = config.Value; _logger = logger; _downloader = downloader; _tunnelSupervisor = tunnelSupervisor; + _managerRpc = managerRpc; + _managerRpc.OnReceive += HandleClientRpcMessage; } public void Dispose() { + _managerRpc.OnReceive -= HandleClientRpcMessage; GC.SuppressFinalize(this); } + public async Task StopAsync(CancellationToken ct = default) + { + await _tunnelSupervisor.StopAsync(ct); + await BroadcastStatus(null, ct); + } + /// /// Processes a message sent from a Client to the ManagerRpcService over the codervpn RPC protocol. /// /// Client message /// Cancellation token - public async Task HandleClientRpcMessage(ReplyableRpcMessage message, + public async Task HandleClientRpcMessage(ulong clientId, ReplyableRpcMessage message, CancellationToken ct = default) { - _logger.LogInformation("ClientMessage: {MessageType}", message.Message.MsgCase); - switch (message.Message.MsgCase) + using (_logger.BeginScope("ClientMessage.{MessageType} (client: {ClientId})", message.Message.MsgCase, + clientId)) { - case ClientMessage.MsgOneofCase.Start: - // TODO: these sub-methods should be managed by some Task list and cancelled/awaited on stop - var startResponse = await HandleClientMessageStart(message.Message, ct); - await message.SendReply(new ServiceMessage - { - Start = startResponse, - }, ct); - break; - case ClientMessage.MsgOneofCase.Stop: - var stopResponse = await HandleClientMessageStop(message.Message, ct); - await message.SendReply(new ServiceMessage - { - Stop = stopResponse, - }, ct); - break; - case ClientMessage.MsgOneofCase.None: - default: - _logger.LogWarning("Received unknown message type {MessageType}", message.Message.MsgCase); - break; + switch (message.Message.MsgCase) + { + case ClientMessage.MsgOneofCase.Start: + // TODO: these sub-methods should be managed by some Task list and cancelled/awaited on stop + var startResponse = await HandleClientMessageStart(message.Message, ct); + await message.SendReply(new ServiceMessage + { + Start = startResponse, + }, ct); + break; + case ClientMessage.MsgOneofCase.Stop: + var stopResponse = await HandleClientMessageStop(message.Message, ct); + await message.SendReply(new ServiceMessage + { + Stop = stopResponse, + }, ct); + await BroadcastStatus(null, ct); + break; + case ClientMessage.MsgOneofCase.Status: + await message.SendReply(new ServiceMessage + { + Status = await CurrentStatus(ct), + }, ct); + break; + case ClientMessage.MsgOneofCase.None: + default: + _logger.LogWarning("Received unknown message type {MessageType}", message.Message.MsgCase); + break; + } } } - public async Task StopAsync(CancellationToken ct = default) - { - await _tunnelSupervisor.StopAsync(ct); - } - private async ValueTask HandleClientMessageStart(ClientMessage message, CancellationToken ct) { var opLock = await _tunnelOperationLock.LockAsync(TimeSpan.FromMilliseconds(500), ct); if (opLock == null) { - _logger.LogWarning("ClientMessage.Start: Tunnel operation lock timed out"); + _logger.LogWarning("Tunnel operation lock timed out"); return new StartResponse { Success = false, @@ -109,18 +135,20 @@ private async ValueTask HandleClientMessageStart(ClientMessage me var serverVersion = await CheckServerVersionAndCredentials(message.Start.CoderUrl, message.Start.ApiToken, ct); - if (_tunnelSupervisor.IsRunning && _lastStartRequest != null && + if (_status == TunnelStatus.Started && _lastStartRequest != null && _lastStartRequest.Equals(message.Start) && _lastServerVersion == serverVersion) { // The client is requesting to start an identical tunnel while // we're already running it. - _logger.LogInformation("ClientMessage.Start: Ignoring duplicate start request"); + _logger.LogInformation("Ignoring duplicate start request"); return new StartResponse { Success = true, }; } + ClearPeers(); + await BroadcastStatus(TunnelStatus.Starting, ct); _lastStartRequest = message.Start; _lastServerVersion = serverVersion; @@ -139,11 +167,14 @@ await _tunnelSupervisor.StartAsync(_config.TunnelBinaryPath, HandleTunnelRpcMess }, ct); if (reply.MsgCase != TunnelMessage.MsgOneofCase.Start) throw new InvalidOperationException("Tunnel did not reply with a Start response"); + + await BroadcastStatus(reply.Start.Success ? TunnelStatus.Started : TunnelStatus.Stopped, ct); return reply.Start; } catch (Exception e) { - _logger.LogWarning(e, "ClientMessage.Start: Failed to start VPN client"); + await BroadcastStatus(TunnelStatus.Stopped, ct); + _logger.LogWarning(e, "Failed to start VPN client"); return new StartResponse { Success = false, @@ -159,7 +190,7 @@ private async ValueTask HandleClientMessageStop(ClientMessage mess var opLock = await _tunnelOperationLock.LockAsync(TimeSpan.FromMilliseconds(500), ct); if (opLock == null) { - _logger.LogWarning("ClientMessage.Stop: Tunnel operation lock timed out"); + _logger.LogWarning("Tunnel operation lock timed out"); return new StopResponse { Success = false, @@ -171,6 +202,8 @@ private async ValueTask HandleClientMessageStop(ClientMessage mess { try { + ClearPeers(); + await BroadcastStatus(TunnelStatus.Stopping, ct); // This will handle sending the Stop message to the tunnel for us. await _tunnelSupervisor.StopAsync(ct); return new StopResponse @@ -180,19 +213,110 @@ private async ValueTask HandleClientMessageStop(ClientMessage mess } catch (Exception e) { - _logger.LogWarning(e, "ClientMessage.Stop: Failed to stop VPN client"); + _logger.LogWarning(e, "Failed to stop VPN client"); return new StopResponse { Success = false, ErrorMessage = e.ToString(), }; } + finally + { + // Always assume it's stopped. + await BroadcastStatus(TunnelStatus.Stopped, ct); + } } } private void HandleTunnelRpcMessage(ReplyableRpcMessage message) { - // TODO: this + using (_logger.BeginScope("TunnelMessage.{MessageType}", message.Message.MsgCase)) + { + switch (message.Message.MsgCase) + { + case TunnelMessage.MsgOneofCase.Start: + case TunnelMessage.MsgOneofCase.Stop: + _logger.LogWarning("Received unexpected message reply type {MessageType}", message.Message.MsgCase); + break; + case TunnelMessage.MsgOneofCase.Log: + case TunnelMessage.MsgOneofCase.NetworkSettings: + _logger.LogWarning("Received message type {MessageType} that is not expected on Windows", + message.Message.MsgCase); + break; + case TunnelMessage.MsgOneofCase.PeerUpdate: + HandleTunnelMessagePeerUpdate(message.Message); + BroadcastStatus().Wait(); + break; + case TunnelMessage.MsgOneofCase.None: + default: + _logger.LogWarning("Received unknown message type {MessageType}", message.Message.MsgCase); + break; + } + } + } + + private void ClearPeers() + { + using var _ = _statusLock.Lock(); + _trackedWorkspaces.Clear(); + _trackedAgents.Clear(); + } + + private void HandleTunnelMessagePeerUpdate(TunnelMessage message) + { + using var _ = _statusLock.Lock(); + foreach (var newWorkspace in message.PeerUpdate.UpsertedWorkspaces) + { + _trackedWorkspaces.RemoveAll(w => w.Id == newWorkspace.Id); + _trackedWorkspaces.Add(newWorkspace); + } + + foreach (var removedWorkspace in message.PeerUpdate.DeletedWorkspaces) + _trackedWorkspaces.RemoveAll(w => w.Id == removedWorkspace.Id); + foreach (var newAgent in message.PeerUpdate.UpsertedAgents) + { + _trackedAgents.RemoveAll(a => a.Id == newAgent.Id); + _trackedAgents.Add(newAgent); + } + + foreach (var removedAgent in message.PeerUpdate.DeletedAgents) + _trackedAgents.RemoveAll(a => a.Id == removedAgent.Id); + + _trackedWorkspaces.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal)); + _trackedAgents.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal)); + } + + private async ValueTask CurrentStatus(CancellationToken ct = default) + { + using var _ = await _statusLock.LockAsync(ct); + var lifecycle = _status switch + { + TunnelStatus.Starting => Status.Types.Lifecycle.Starting, + TunnelStatus.Started => Status.Types.Lifecycle.Started, + TunnelStatus.Stopping => Status.Types.Lifecycle.Stopping, + TunnelStatus.Stopped => Status.Types.Lifecycle.Stopped, + _ => Status.Types.Lifecycle.Stopped, + }; + + return new Status + { + Lifecycle = lifecycle, + ErrorMessage = "", + PeerUpdate = new PeerUpdate + { + UpsertedAgents = { _trackedAgents }, + UpsertedWorkspaces = { _trackedWorkspaces }, + }, + }; + } + + private async Task BroadcastStatus(TunnelStatus? newStatus = null, CancellationToken ct = default) + { + if (newStatus != null) _status = newStatus.Value; + await _managerRpc.BroadcastAsync(new ServiceMessage + { + Status = await CurrentStatus(ct), + }, ct); } private void HandleTunnelRpcError(Exception e) @@ -201,7 +325,8 @@ private void HandleTunnelRpcError(Exception e) try { _tunnelSupervisor.StopAsync(); - // TODO: this should broadcast an update to all clients + ClearPeers(); + BroadcastStatus().Wait(); } catch (Exception e2) { diff --git a/Vpn.Service/ManagerRpc.cs b/Vpn.Service/ManagerRpc.cs new file mode 100644 index 0000000..5d27def --- /dev/null +++ b/Vpn.Service/ManagerRpc.cs @@ -0,0 +1,193 @@ +using System.Collections.Concurrent; +using System.IO.Pipes; +using System.Security.AccessControl; +using System.Security.Principal; +using Coder.Desktop.Vpn.Proto; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Coder.Desktop.Vpn.Service; + +public class ManagerRpcClient(Speaker speaker, Task task) +{ + public Speaker Speaker { get; } = speaker; + public Task Task { get; } = task; +} + +public interface IManagerRpc : IAsyncDisposable +{ + delegate Task OnReceiveHandler(ulong clientId, ReplyableRpcMessage message, + CancellationToken ct = default); + + event OnReceiveHandler? OnReceive; + + Task StopAsync(CancellationToken cancellationToken); + + Task ExecuteAsync(CancellationToken stoppingToken); + + Task BroadcastAsync(ServiceMessage message, CancellationToken ct = default); +} + +/// +/// Provides a named pipe server for communication between multiple RpcRole.Client and RpcRole.Manager. +/// +public class ManagerRpc : IManagerRpc +{ + private readonly ConcurrentDictionary _activeClients = new(); + private readonly ManagerConfig _config; + private readonly CancellationTokenSource _cts = new(); + private readonly ILogger _logger; + private ulong _lastClientId; + + // ReSharper disable once ConvertToPrimaryConstructor + public ManagerRpc(IOptions config, ILogger logger) + { + _logger = logger; + _config = config.Value; + } + + public event IManagerRpc.OnReceiveHandler? OnReceive; + + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + while (!_activeClients.IsEmpty) await Task.WhenAny(_activeClients.Values.Select(c => c.Task)); + _cts.Dispose(); + GC.SuppressFinalize(this); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + await _cts.CancelAsync(); + while (!_activeClients.IsEmpty) await Task.WhenAny(_activeClients.Values.Select(c => c.Task)); + } + + /// + /// Starts the named pipe server, listens for incoming connections and starts handling them asynchronously. + /// + public async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation(@"Starting continuous named pipe RPC server at \\.\pipe\{PipeName}", + _config.ServiceRpcPipeName); + + // Allow everyone to connect to the named pipe + var pipeSecurity = new PipeSecurity(); + pipeSecurity.AddAccessRule(new PipeAccessRule( + new SecurityIdentifier(WellKnownSidType.WorldSid, null), + PipeAccessRights.FullControl, + AccessControlType.Allow)); + + // Starting a named pipe server is not like a TCP server where you can + // continuously accept new connections. You need to recreate the server + // after accepting a connection in order to accept new connections. + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _cts.Token); + while (!linkedCts.IsCancellationRequested) + { + var pipeServer = NamedPipeServerStreamAcl.Create(_config.ServiceRpcPipeName, PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte, PipeOptions.Asynchronous, 0, + 0, pipeSecurity); + + try + { + _logger.LogDebug("Waiting for new named pipe client connection"); + await pipeServer.WaitForConnectionAsync(linkedCts.Token); + + var clientId = Interlocked.Add(ref _lastClientId, 1); + _logger.LogInformation("Handling named pipe client connection for client {ClientId}", clientId); + var speaker = new Speaker(pipeServer); + var clientTask = HandleRpcClientAsync(clientId, speaker, linkedCts.Token); + _activeClients.TryAdd(clientId, new ManagerRpcClient(speaker, clientTask)); + _ = clientTask.ContinueWith(task => + { + if (task.IsFaulted) + _logger.LogWarning(task.Exception, "Client {ClientId} RPC task faulted", clientId); + _activeClients.TryRemove(clientId, out _); + }, CancellationToken.None); + } + catch (OperationCanceledException) + { + await pipeServer.DisposeAsync(); + throw; + } + catch (Exception e) + { + _logger.LogWarning(e, "Failed to accept named pipe client"); + await pipeServer.DisposeAsync(); + } + } + } + + public async Task BroadcastAsync(ServiceMessage message, CancellationToken ct) + { + // Looping over a ConcurrentDictionary is exception-safe, but any items + // added or removed during the loop may or may not be included. + foreach (var (clientId, client) in _activeClients) + try + { + var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(5 * 1000); + await client.Speaker.SendMessage(message, cts.Token); + } + catch (ObjectDisposedException) + { + // The speaker was likely closed while we were iterating. + } + catch (Exception e) + { + _logger.LogWarning(e, "Failed to send message to client {ClientId}", clientId); + // TODO: this should probably kill the client, but due to the + // async nature of the client handling, calling Dispose + // will not remove the client from the active clients list + } + } + + private async Task HandleRpcClientAsync(ulong clientId, Speaker speaker, + CancellationToken ct) + { + var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token); + await using (speaker) + { + var tcs = new TaskCompletionSource(); + var activeTasks = new ConcurrentDictionary(); + speaker.Receive += msg => + { + var task = HandleRpcMessageAsync(clientId, msg, linkedCts.Token); + activeTasks.TryAdd(task.Id, task); + task.ContinueWith(t => + { + if (t.IsFaulted) + _logger.LogWarning(t.Exception, "Client {ClientId} RPC message handler task faulted", clientId); + activeTasks.TryRemove(t.Id, out _); + }, CancellationToken.None); + }; + speaker.Error += tcs.SetException; + speaker.Error += exception => + { + _logger.LogWarning(exception, "Client {clientId} RPC speaker error", clientId); + }; + await using (ct.Register(() => tcs.SetCanceled(ct))) + { + await speaker.StartAsync(ct); + await tcs.Task; + await linkedCts.CancelAsync(); + while (!activeTasks.IsEmpty) + await Task.WhenAny(activeTasks.Values); + } + } + } + + private async Task HandleRpcMessageAsync(ulong clientId, ReplyableRpcMessage message, + CancellationToken ct) + { + _logger.LogInformation("Received RPC message from client {ClientId}: {Message}", clientId, message.Message); + foreach (var handler in OnReceive?.GetInvocationList().Cast() ?? []) + try + { + await handler(clientId, message, ct); + } + catch (Exception e) + { + _logger.LogWarning(e, "Failed to handle RPC message from client {ClientId} with handler", clientId); + } + } +} diff --git a/Vpn.Service/ManagerRpcService.cs b/Vpn.Service/ManagerRpcService.cs index eb3cd0b..06eaa64 100644 --- a/Vpn.Service/ManagerRpcService.cs +++ b/Vpn.Service/ManagerRpcService.cs @@ -1,168 +1,24 @@ -using System.Collections.Concurrent; -using System.IO.Pipes; -using System.Security.AccessControl; -using System.Security.Principal; -using Coder.Desktop.Vpn.Proto; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace Coder.Desktop.Vpn.Service; -public class ManagerRpcClient(Speaker speaker, Task task) +public class ManagerRpcService : BackgroundService { - public Speaker Speaker { get; } = speaker; - public Task Task { get; } = task; -} - -/// -/// Provides a named pipe server for communication between multiple RpcRole.Client and RpcRole.Manager. -/// -public class ManagerRpcService : BackgroundService, IAsyncDisposable -{ - private readonly ConcurrentDictionary _activeClients = new(); - private readonly ManagerConfig _config; - private readonly CancellationTokenSource _cts = new(); - private readonly ILogger _logger; - private readonly IManager _manager; - private ulong _lastClientId; + private readonly IManagerRpc _managerRpc; // ReSharper disable once ConvertToPrimaryConstructor - public ManagerRpcService(IOptions config, ILogger logger, IManager manager) - { - _logger = logger; - _manager = manager; - _config = config.Value; - } - - public async ValueTask DisposeAsync() + public ManagerRpcService(IManagerRpc managerRpc) { - await _cts.CancelAsync(); - while (!_activeClients.IsEmpty) await Task.WhenAny(_activeClients.Values.Select(c => c.Task)); - _cts.Dispose(); - GC.SuppressFinalize(this); + _managerRpc = managerRpc; } public override async Task StopAsync(CancellationToken cancellationToken) { - await _cts.CancelAsync(); - while (!_activeClients.IsEmpty) await Task.WhenAny(_activeClients.Values.Select(c => c.Task)); + await _managerRpc.StopAsync(cancellationToken); } - /// - /// Starts the named pipe server, listens for incoming connections and starts handling them asynchronously. - /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogInformation(@"Starting continuous named pipe RPC server at \\.\pipe\{PipeName}", - _config.ServiceRpcPipeName); - - // Allow everyone to connect to the named pipe - var pipeSecurity = new PipeSecurity(); - pipeSecurity.AddAccessRule(new PipeAccessRule( - new SecurityIdentifier(WellKnownSidType.WorldSid, null), - PipeAccessRights.FullControl, - AccessControlType.Allow)); - - // Starting a named pipe server is not like a TCP server where you can - // continuously accept new connections. You need to recreate the server - // after accepting a connection in order to accept new connections. - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _cts.Token); - while (!linkedCts.IsCancellationRequested) - { - var pipeServer = NamedPipeServerStreamAcl.Create(_config.ServiceRpcPipeName, PipeDirection.InOut, - NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte, PipeOptions.Asynchronous, 0, - 0, pipeSecurity); - - try - { - _logger.LogDebug("Waiting for new named pipe client connection"); - await pipeServer.WaitForConnectionAsync(linkedCts.Token); - - var clientId = Interlocked.Add(ref _lastClientId, 1); - _logger.LogInformation("Handling named pipe client connection for client {ClientId}", clientId); - var speaker = new Speaker(pipeServer); - var clientTask = HandleRpcClientAsync(speaker, linkedCts.Token); - _activeClients.TryAdd(clientId, new ManagerRpcClient(speaker, clientTask)); - _ = clientTask.ContinueWith(task => - { - if (task.IsFaulted) - _logger.LogWarning(task.Exception, "Client {ClientId} RPC task faulted", clientId); - _activeClients.TryRemove(clientId, out _); - }, CancellationToken.None); - } - catch (OperationCanceledException) - { - await pipeServer.DisposeAsync(); - throw; - } - catch (Exception e) - { - _logger.LogWarning(e, "Failed to accept named pipe client"); - await pipeServer.DisposeAsync(); - } - } - } - - private async Task HandleRpcClientAsync(Speaker speaker, CancellationToken ct) - { - var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token); - await using (speaker) - { - var tcs = new TaskCompletionSource(); - var activeTasks = new ConcurrentDictionary(); - speaker.Receive += msg => - { - var task = HandleRpcMessageAsync(msg, linkedCts.Token); - activeTasks.TryAdd(task.Id, task); - task.ContinueWith(t => - { - if (t.IsFaulted) - _logger.LogWarning(t.Exception, "Client RPC message handler task faulted"); - activeTasks.TryRemove(t.Id, out _); - }, CancellationToken.None); - }; - speaker.Error += tcs.SetException; - speaker.Error += exception => { _logger.LogWarning(exception, "Client RPC speaker error"); }; - await using (ct.Register(() => tcs.SetCanceled(ct))) - { - await speaker.StartAsync(ct); - await tcs.Task; - await linkedCts.CancelAsync(); - while (!activeTasks.IsEmpty) - await Task.WhenAny(activeTasks.Values); - } - } - } - - private async Task HandleRpcMessageAsync(ReplyableRpcMessage message, - CancellationToken ct) - { - _logger.LogInformation("Received RPC message: {Message}", message.Message); - await _manager.HandleClientRpcMessage(message, ct); - } - - public async Task BroadcastAsync(ServiceMessage message, CancellationToken ct) - { - // Looping over a ConcurrentDictionary is exception-safe, but any items - // added or removed during the loop may or may not be included. - foreach (var (clientId, client) in _activeClients) - try - { - var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - cts.CancelAfter(5 * 1000); - await client.Speaker.SendMessage(message, cts.Token); - } - catch (ObjectDisposedException) - { - // The speaker was likely closed while we were iterating. - } - catch (Exception e) - { - _logger.LogWarning(e, "Failed to send message to client {ClientId}", clientId); - // TODO: this should probably kill the client, but due to the - // async nature of the client handling, calling Dispose - // will not remove the client from the active clients list - } + await _managerRpc.ExecuteAsync(stoppingToken); } } diff --git a/Vpn.Service/Program.cs b/Vpn.Service/Program.cs index e46e674..c2a1037 100644 --- a/Vpn.Service/Program.cs +++ b/Vpn.Service/Program.cs @@ -9,9 +9,9 @@ namespace Coder.Desktop.Vpn.Service; public static class Program { #if DEBUG - private const string serviceName = "Coder Desktop (Debug)"; + private const string ServiceName = "Coder Desktop (Debug)"; #else - const string serviceName = "Coder Desktop"; + const string ServiceName = "Coder Desktop"; #endif private static readonly ILogger MainLogger = Log.ForContext("SourceContext", "Coder.Desktop.Vpn.Service.Program"); @@ -69,14 +69,14 @@ private static async Task BuildAndRun(string[] args) // Singletons builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Services - // TODO: is this sound enough to determine if we're a service? if (!Environment.UserInteractive) { MainLogger.Information("Running as a windows service"); - builder.Services.AddWindowsService(options => { options.ServiceName = serviceName; }); + builder.Services.AddWindowsService(options => { options.ServiceName = ServiceName; }); } else { diff --git a/Vpn.Service/TunnelSupervisor.cs b/Vpn.Service/TunnelSupervisor.cs index b02d893..a323cac 100644 --- a/Vpn.Service/TunnelSupervisor.cs +++ b/Vpn.Service/TunnelSupervisor.cs @@ -3,13 +3,13 @@ using Coder.Desktop.Vpn.Proto; using Coder.Desktop.Vpn.Utilities; using Microsoft.Extensions.Logging; +using Log = Serilog.Log; +using Process = System.Diagnostics.Process; namespace Coder.Desktop.Vpn.Service; public interface ITunnelSupervisor : IAsyncDisposable { - public bool IsRunning { get; } - /// /// Starts the tunnel subprocess with the given executable path. If the subprocess is already running, this method will /// kill it first. @@ -62,7 +62,6 @@ public class TunnelSupervisor : ITunnelSupervisor private AnonymousPipeServerStream? _inPipe; private AnonymousPipeServerStream? _outPipe; private Speaker? _speaker; - private Process? _subprocess; // ReSharper disable once ConvertToPrimaryConstructor @@ -71,8 +70,6 @@ public TunnelSupervisor(ILogger logger) _logger = logger; } - public bool IsRunning => _speaker != null; - public async Task StartAsync(string binPath, Speaker.OnReceiveDelegate messageHandler, Speaker.OnErrorDelegate errorHandler, @@ -101,15 +98,19 @@ public async Task StartAsync(string binPath, RedirectStandardOutput = true, }, }; + // TODO: maybe we should change the log format in the inner binary + // to something without a timestamp + var outLogger = Log.ForContext("SourceContext", "coder-vpn.exe[OUT]"); + var errLogger = Log.ForContext("SourceContext", "coder-vpn.exe[ERR]"); _subprocess.OutputDataReceived += (_, args) => { if (!string.IsNullOrWhiteSpace(args.Data)) - _logger.LogDebug("OUT: {Data}", args.Data); + outLogger.Debug("{Data}", args.Data); }; _subprocess.ErrorDataReceived += (_, args) => { if (!string.IsNullOrWhiteSpace(args.Data)) - _logger.LogDebug("ERR: {Data}", args.Data); + errLogger.Debug("{Data}", args.Data); }; // Pass the other end of the pipes to the subprocess and dispose