From 47c7fdb9a25d3fe9d0379ce471381fdf0b86fc8c Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Fri, 7 Feb 2025 23:06:24 +1100 Subject: [PATCH 1/4] 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 From b01d2e10028095e0ac7f0b5eed5c6422c8cca813 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Fri, 7 Feb 2025 23:11:20 +1100 Subject: [PATCH 2/4] fixup! feat: add agent status to tray app --- Vpn.Proto/vpn.proto | 288 ++++++++++++++++++++++---------------------- 1 file changed, 144 insertions(+), 144 deletions(-) diff --git a/Vpn.Proto/vpn.proto b/Vpn.Proto/vpn.proto index 6da7a66..820eebf 100644 --- a/Vpn.Proto/vpn.proto +++ b/Vpn.Proto/vpn.proto @@ -17,31 +17,31 @@ 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). Windows only. @@ -50,7 +50,7 @@ message ClientMessage { oneof msg { StartRequest start = 2; StopRequest stop = 3; - StatusRequest status = 4; + StatusRequest status = 4; } } @@ -60,32 +60,32 @@ message ServiceMessage { oneof msg { StartResponse start = 2; StopResponse stop = 3; - Status status = 4; // either in reply to a StatusRequest or broadcasted + 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. @@ -95,121 +95,121 @@ 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 to stop the tunnel. The tunnel replies with a @@ -219,8 +219,8 @@ 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 @@ -230,18 +230,18 @@ 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; + 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; } From a51c53ceb7d8411c65ac25642085cdb825116933 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 12 Feb 2025 19:31:26 +1100 Subject: [PATCH 3/4] comments --- App/Converters/VpnLifecycleToBoolConverter.cs | 2 ++ App/Models/RpcModel.cs | 3 ++- App/Services/RpcController.cs | 6 +++--- App/ViewModels/TrayWindowViewModel.cs | 5 ++--- Vpn.Proto/vpn.proto | 4 ++-- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/App/Converters/VpnLifecycleToBoolConverter.cs b/App/Converters/VpnLifecycleToBoolConverter.cs index 86e66aa..a2e3805 100644 --- a/App/Converters/VpnLifecycleToBoolConverter.cs +++ b/App/Converters/VpnLifecycleToBoolConverter.cs @@ -6,6 +6,7 @@ namespace Coder.Desktop.App.Converters; +[DependencyProperty("Unknown", DefaultValue = false)] [DependencyProperty("Starting", DefaultValue = false)] [DependencyProperty("Started", DefaultValue = false)] [DependencyProperty("Stopping", DefaultValue = false)] @@ -18,6 +19,7 @@ public object Convert(object value, Type targetType, object parameter, string la return lifecycle switch { + VpnLifecycle.Unknown => Unknown, VpnLifecycle.Starting => Starting, VpnLifecycle.Started => Started, VpnLifecycle.Stopping => Stopping, diff --git a/App/Models/RpcModel.cs b/App/Models/RpcModel.cs index 3272742..dacef38 100644 --- a/App/Models/RpcModel.cs +++ b/App/Models/RpcModel.cs @@ -13,6 +13,7 @@ public enum RpcLifecycle public enum VpnLifecycle { + Unknown, Stopped, Starting, Started, @@ -23,7 +24,7 @@ public class RpcModel { public RpcLifecycle RpcLifecycle { get; set; } = RpcLifecycle.Disconnected; - public VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Stopped; + public VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown; public List Workspaces { get; set; } = []; diff --git a/App/Services/RpcController.cs b/App/Services/RpcController.cs index 01484c7..07ae38e 100644 --- a/App/Services/RpcController.cs +++ b/App/Services/RpcController.cs @@ -126,7 +126,7 @@ public async Task Reconnect(CancellationToken ct = default) MutateState(state => { state.RpcLifecycle = RpcLifecycle.Disconnected; - state.VpnLifecycle = VpnLifecycle.Stopped; + state.VpnLifecycle = VpnLifecycle.Unknown; state.Workspaces.Clear(); state.Agents.Clear(); }); @@ -136,7 +136,7 @@ public async Task Reconnect(CancellationToken ct = default) MutateState(state => { state.RpcLifecycle = RpcLifecycle.Connected; - state.VpnLifecycle = VpnLifecycle.Stopping; // prevents clicking the toggle + state.VpnLifecycle = VpnLifecycle.Unknown; state.Workspaces.Clear(); state.Agents.Clear(); }); @@ -250,7 +250,7 @@ private void ApplyStatusUpdate(Status status) { state.VpnLifecycle = status.Lifecycle switch { - Status.Types.Lifecycle.Unknown => VpnLifecycle.Stopping, // disables the switch + Status.Types.Lifecycle.Unknown => VpnLifecycle.Unknown, Status.Types.Lifecycle.Starting => VpnLifecycle.Starting, Status.Types.Lifecycle.Started => VpnLifecycle.Started, Status.Types.Lifecycle.Stopping => VpnLifecycle.Stopping, diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index 576bf72..5fcd84e 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -22,8 +22,7 @@ public partial class TrayWindowViewModel : ObservableObject private DispatcherQueue? _dispatcherQueue; [ObservableProperty] - public partial VpnLifecycle VpnLifecycle { get; set; } = - VpnLifecycle.Stopping; // to prevent interaction until we get the real state + public partial VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown; // VpnSwitchOn needs to be its own property as it is a two-way binding [ObservableProperty] @@ -82,7 +81,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel) // Window should not show the current Page if the RPC is disconnected. if (rpcModel.RpcLifecycle is RpcLifecycle.Disconnected) { - VpnLifecycle = VpnLifecycle.Stopping; + VpnLifecycle = VpnLifecycle.Unknown; VpnSwitchOn = false; Agents = []; return; diff --git a/Vpn.Proto/vpn.proto b/Vpn.Proto/vpn.proto index 820eebf..8a4800d 100644 --- a/Vpn.Proto/vpn.proto +++ b/Vpn.Proto/vpn.proto @@ -50,7 +50,7 @@ message ClientMessage { oneof msg { StartRequest start = 2; StopRequest stop = 3; - StatusRequest status = 4; + StatusRequest status = 4; } } @@ -60,7 +60,7 @@ message ServiceMessage { oneof msg { StartResponse start = 2; StopResponse stop = 3; - Status status = 4; // either in reply to a StatusRequest or broadcasted + Status status = 4; // either in reply to a StatusRequest or broadcasted } } From 62b01c812a306f1dc99c9fc4d34c4a0b6c662330 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 12 Feb 2025 19:34:52 +1100 Subject: [PATCH 4/4] fixup! comments --- .github/workflows/ci.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6575860..8c42c13 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -51,10 +51,10 @@ jobs: cache-dependency-path: '**/packages.lock.json' - name: dotnet restore run: dotnet restore --locked-mode - - name: dotnet publish - run: dotnet publish --no-restore --configuration Release --output .\publish - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: publish - path: .\publish\ + #- name: dotnet publish + # run: dotnet publish --no-restore --configuration Release --output .\publish + #- name: Upload artifact + # uses: actions/upload-artifact@v4 + # with: + # name: publish + # path: .\publish\ pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy