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\ 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 cce3650..515d404 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -12,7 +12,6 @@ namespace Coder.Desktop.App; public partial class App : Application { private readonly IServiceProvider _services; - private readonly bool _handleClosedEvents = true; public App() { @@ -49,12 +48,8 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) var trayWindow = _services.GetRequiredService(); 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/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 074578f..dacef38 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; @@ -11,6 +13,7 @@ public enum RpcLifecycle public enum VpnLifecycle { + Unknown, Stopped, Starting, Started, @@ -21,9 +24,11 @@ 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 Agents { get; set; } = []; + public List Workspaces { get; set; } = []; + + public List Agents { get; set; } = []; public RpcModel Clone() { @@ -31,7 +36,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 af1dbae..05dceec 100644 --- a/App/Services/CredentialManager.cs +++ b/App/Services/CredentialManager.cs @@ -66,12 +66,14 @@ public async Task SetCredentials(string coderUrl, string apiToken, CancellationT try { + var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(15)); var sdkClient = new CoderApiClient(uri); sdkClient.SetSessionToken(apiToken); // 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) { diff --git a/App/Services/RpcController.cs b/App/Services/RpcController.cs index 70ae8f3..07ae38e 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(); }); @@ -125,7 +126,8 @@ 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(); }); 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.Unknown; + 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.Unknown, + 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..5fcd84e 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,9 +19,10 @@ 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 + public partial VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown; // VpnSwitchOn needs to be its own property as it is a two-way binding [ObservableProperty] @@ -32,7 +35,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 +54,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,11 +69,19 @@ 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) { - VpnLifecycle = VpnLifecycle.Stopping; + VpnLifecycle = VpnLifecycle.Unknown; VpnSwitchOn = false; Agents = []; return; @@ -73,52 +89,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 +193,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/SignInTokenPage.xaml b/App/Views/Pages/SignInTokenPage.xaml index dde2d5c..a09efb8 100644 --- a/App/Views/Pages/SignInTokenPage.xaml +++ b/App/Views/Pages/SignInTokenPage.xaml @@ -63,7 +63,8 @@ HorizontalAlignment="Stretch" PlaceholderText="Paste your token here" LostFocus="{x:Bind ViewModel.ApiToken_FocusLost, Mode=OneWay}" - Text="{x:Bind ViewModel.ApiToken, Mode=TwoWay}" /> + Text="{x:Bind ViewModel.ApiToken, Mode=TwoWay}" + InputScope="Password" /> ()); - if (result != 0) throw new Exception("Failed to set window corner preference"); + // Best effort. This does not work on Windows 10. + _ = NativeApi.DwmSetWindowAttribute(windowHandle, 33, ref value, Marshal.SizeOf()); } private void SetPageByState(RpcModel rpcModel, CredentialModel credentialModel) @@ -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..8a4800d 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"; @@ -44,21 +44,23 @@ message TunnelMessage { } } -// 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 } } @@ -210,7 +212,7 @@ message StartResponse { 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 {} @@ -220,3 +222,26 @@ message StopResponse { 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 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