diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..d0323ad
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,81 @@
+[*]
+indent_style = space
+indent_size = 4
+tab_width = 4
+trim_trailing_whitespace = true
+insert_final_newline = true
+charset = utf-8
+end_of_line = crlf
+
+resharper_csharp_brace_style = next_line
+resharper_csharp_braces_for_foreach = not_required
+resharper_csharp_braces_for_for = not_required
+resharper_csharp_braces_for_while = not_required
+
+# Microsoft .NET properties
+csharp_new_line_before_members_in_object_initializers = false
+csharp_preferred_modifier_order = public, private, protected, internal, file, new, static, abstract, virtual, sealed, readonly, override, extern, unsafe, volatile, async, required:suggestion
+csharp_style_prefer_utf8_string_literals = true:suggestion
+csharp_style_var_elsewhere = true:suggestion
+csharp_style_var_for_built_in_types = true:suggestion
+csharp_style_var_when_type_is_apparent = true:suggestion
+dotnet_naming_rule.unity_serialized_field_rule.import_to_resharper = True
+dotnet_naming_rule.unity_serialized_field_rule.resharper_description = Unity serialized field
+dotnet_naming_rule.unity_serialized_field_rule.resharper_guid = 5f0fdb63-c892-4d2c-9324-15c80b22a7ef
+dotnet_naming_rule.unity_serialized_field_rule.severity = warning
+dotnet_naming_rule.unity_serialized_field_rule.style = lower_camel_case_style
+dotnet_naming_rule.unity_serialized_field_rule.symbols = unity_serialized_field_symbols
+dotnet_naming_style.lower_camel_case_style.capitalization = camel_case
+dotnet_naming_symbols.unity_serialized_field_symbols.applicable_accessibilities = *
+dotnet_naming_symbols.unity_serialized_field_symbols.applicable_kinds =
+dotnet_naming_symbols.unity_serialized_field_symbols.resharper_applicable_kinds = unity_serialised_field
+dotnet_naming_symbols.unity_serialized_field_symbols.resharper_required_modifiers = instance
+dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:none
+dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:none
+dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:none
+dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
+dotnet_style_predefined_type_for_member_access = true:suggestion
+dotnet_style_qualification_for_event = false:suggestion
+dotnet_style_qualification_for_field = false:suggestion
+dotnet_style_qualification_for_method = false:suggestion
+dotnet_style_qualification_for_property = false:suggestion
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion
+
+# ReSharper properties
+resharper_autodetect_indent_settings = true
+resharper_formatter_off_tag = @formatter:off
+resharper_formatter_on_tag = @formatter:on
+resharper_formatter_tags_enabled = true
+resharper_no_indent_inside_if_element_longer_than = 2147483647
+resharper_trailing_comma_in_multiline_lists = true
+resharper_use_indent_from_vs = false
+
+# ReSharper inspection severities
+resharper_arrange_redundant_parentheses_highlighting = hint
+resharper_arrange_this_qualifier_highlighting = hint
+resharper_arrange_type_member_modifiers_highlighting = hint
+resharper_arrange_type_modifiers_highlighting = hint
+resharper_built_in_type_reference_style_for_member_access_highlighting = hint
+resharper_built_in_type_reference_style_highlighting = hint
+resharper_mvc_action_not_resolved_highlighting = warning
+resharper_mvc_area_not_resolved_highlighting = warning
+resharper_mvc_controller_not_resolved_highlighting = warning
+resharper_mvc_masterpage_not_resolved_highlighting = warning
+resharper_mvc_partial_view_not_resolved_highlighting = warning
+resharper_mvc_template_not_resolved_highlighting = warning
+resharper_mvc_view_component_not_resolved_highlighting = warning
+resharper_mvc_view_component_view_not_resolved_highlighting = warning
+resharper_mvc_view_not_resolved_highlighting = warning
+resharper_razor_assembly_not_resolved_highlighting = warning
+resharper_redundant_base_qualifier_highlighting = warning
+resharper_suggest_var_or_type_built_in_types_highlighting = hint
+resharper_suggest_var_or_type_elsewhere_highlighting = hint
+resharper_suggest_var_or_type_simple_types_highlighting = hint
+resharper_unnecessary_whitespace_highlighting = suggestion
+resharper_web_config_module_not_resolved_highlighting = warning
+resharper_web_config_type_not_resolved_highlighting = warning
+resharper_web_config_wrong_module_highlighting = warning
+
+[{*.json,*.jsonc,*.yml,*.yaml,*.proto}]
+indent_style = space
+indent_size = 2
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..79d0686
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,403 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+# but not Directory.Build.rsp, as it configures directory-level build defaults
+!Directory.Build.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.tlog
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio 6 auto-generated project file (contains which files were open etc.)
+*.vbp
+
+# Visual Studio 6 workspace and project file (working project files containing files to include in project)
+*.dsw
+*.dsp
+
+# Visual Studio 6 technical files
+*.ncb
+*.aps
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# Visual Studio History (VSHistory) files
+.vshistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
+
+# VS Code files for those working on multiple tools
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+*.code-workspace
+
+# Local History for Visual Studio Code
+.history/
+
+# Windows Installer files from build outputs
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# JetBrains Rider
+*.sln.iml
+.idea/**/workspace.xml
+.idea/**/usage.statistics.xml
+.idea/**/shelf
diff --git a/.idea/.idea.Coder.Desktop/.idea/.name b/.idea/.idea.Coder.Desktop/.idea/.name
new file mode 100644
index 0000000..6e47b44
--- /dev/null
+++ b/.idea/.idea.Coder.Desktop/.idea/.name
@@ -0,0 +1 @@
+Coder.Desktop
\ No newline at end of file
diff --git a/.idea/.idea.Coder.Desktop/.idea/indexLayout.xml b/.idea/.idea.Coder.Desktop/.idea/indexLayout.xml
new file mode 100644
index 0000000..7b08163
--- /dev/null
+++ b/.idea/.idea.Coder.Desktop/.idea/indexLayout.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/.idea.Coder.Desktop/.idea/projectSettingsUpdater.xml b/.idea/.idea.Coder.Desktop/.idea/projectSettingsUpdater.xml
new file mode 100644
index 0000000..64af657
--- /dev/null
+++ b/.idea/.idea.Coder.Desktop/.idea/projectSettingsUpdater.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/.idea.Coder.Desktop/.idea/vcs.xml b/.idea/.idea.Coder.Desktop/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/.idea.Coder.Desktop/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Coder.Desktop.sln b/Coder.Desktop.sln
new file mode 100644
index 0000000..342963b
--- /dev/null
+++ b/Coder.Desktop.sln
@@ -0,0 +1,29 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+#
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coder.Desktop.Vpn", "Vpn\Vpn.csproj", "{B342F896-C721-4AA5-A0F6-0BFA8EBAFACB}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coder.Desktop.Vpn.Proto", "Vpn.Proto\Vpn.Proto.csproj", "{318E78BB-E6AD-410F-8F3F-B680F6880293}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coder.Desktop.Tests", "Tests\Tests.csproj", "{D247B2E7-38A0-4A69-A710-7E8FAA7B807E}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {B342F896-C721-4AA5-A0F6-0BFA8EBAFACB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B342F896-C721-4AA5-A0F6-0BFA8EBAFACB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B342F896-C721-4AA5-A0F6-0BFA8EBAFACB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B342F896-C721-4AA5-A0F6-0BFA8EBAFACB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {318E78BB-E6AD-410F-8F3F-B680F6880293}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {318E78BB-E6AD-410F-8F3F-B680F6880293}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {318E78BB-E6AD-410F-8F3F-B680F6880293}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {318E78BB-E6AD-410F-8F3F-B680F6880293}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D247B2E7-38A0-4A69-A710-7E8FAA7B807E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D247B2E7-38A0-4A69-A710-7E8FAA7B807E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D247B2E7-38A0-4A69-A710-7E8FAA7B807E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D247B2E7-38A0-4A69-A710-7E8FAA7B807E}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/Coder.Desktop.sln.DotSettings b/Coder.Desktop.sln.DotSettings
new file mode 100644
index 0000000..636b95d
--- /dev/null
+++ b/Coder.Desktop.sln.DotSettings
@@ -0,0 +1,256 @@
+
+ <Patterns xmlns="urn:schemas-jetbrains-com:member-reordering-patterns">
+ <TypePattern DisplayName="Non-reorderable types" Priority="99999999">
+ <TypePattern.Match>
+ <Or>
+ <And>
+ <Kind Is="Interface" />
+ <Or>
+ <HasAttribute Name="System.Runtime.InteropServices.InterfaceTypeAttribute" />
+ <HasAttribute Name="System.Runtime.InteropServices.ComImport" />
+ </Or>
+ </And>
+ <Kind Is="Struct" />
+ <HasAttribute Name="System.Runtime.InteropServices.StructLayoutAttribute" />
+ <HasAttribute Name="JetBrains.Annotations.NoReorderAttribute" />
+ </Or>
+ </TypePattern.Match>
+ </TypePattern>
+
+ <TypePattern DisplayName="xUnit.net Test Classes" RemoveRegions="All">
+ <TypePattern.Match>
+ <And>
+ <Kind Is="Class" />
+ <HasMember>
+ <And>
+ <Kind Is="Method" />
+ <HasAttribute Name="Xunit.FactAttribute" Inherited="True" />
+ <HasAttribute Name="Xunit.TheoryAttribute" Inherited="True" />
+ </And>
+ </HasMember>
+ </And>
+ </TypePattern.Match>
+
+ <Entry DisplayName="Fields">
+ <Entry.Match>
+ <And>
+ <Kind Is="Field" />
+ <Not>
+ <Static />
+ </Not>
+ </And>
+ </Entry.Match>
+
+ <Entry.SortBy>
+ <Readonly />
+ <Name />
+ </Entry.SortBy>
+ </Entry>
+
+ <Entry DisplayName="Constructors">
+ <Entry.Match>
+ <Kind Is="Constructor" />
+ </Entry.Match>
+
+ <Entry.SortBy>
+ <Static/>
+ </Entry.SortBy>
+ </Entry>
+
+ <Entry DisplayName="Teardown Methods">
+ <Entry.Match>
+ <And>
+ <Kind Is="Method" />
+ <ImplementsInterface Name="System.IDisposable" />
+ </And>
+ </Entry.Match>
+ </Entry>
+
+ <Entry DisplayName="All other members" />
+
+ <Entry DisplayName="Test Methods" Priority="100">
+ <Entry.Match>
+ <And>
+ <Kind Is="Method" />
+ <HasAttribute Name="Xunit.FactAttribute" Inherited="false" />
+ <HasAttribute Name="Xunit.TheoryAttribute" Inherited="false" />
+ </And>
+ </Entry.Match>
+
+ <Entry.SortBy>
+ <Name />
+ </Entry.SortBy>
+ </Entry>
+ </TypePattern>
+
+ <TypePattern DisplayName="NUnit Test Fixtures" RemoveRegions="All">
+ <TypePattern.Match>
+ <And>
+ <Kind Is="Class" />
+ <Or>
+ <HasAttribute Name="NUnit.Framework.TestFixtureAttribute" Inherited="true" />
+ <HasAttribute Name="NUnit.Framework.TestFixtureSourceAttribute" Inherited="true" />
+ <HasMember>
+ <And>
+ <Kind Is="Method" />
+ <HasAttribute Name="NUnit.Framework.TestAttribute" Inherited="false" />
+ <HasAttribute Name="NUnit.Framework.TestCaseAttribute" Inherited="false" />
+ <HasAttribute Name="NUnit.Framework.TestCaseSourceAttribute" Inherited="false" />
+ </And>
+ </HasMember>
+ </Or>
+ </And>
+ </TypePattern.Match>
+
+ <Entry DisplayName="Setup/Teardown Methods">
+ <Entry.Match>
+ <And>
+ <Kind Is="Method" />
+ <Or>
+ <HasAttribute Name="NUnit.Framework.SetUpAttribute" Inherited="true" />
+ <HasAttribute Name="NUnit.Framework.TearDownAttribute" Inherited="true" />
+ <HasAttribute Name="NUnit.Framework.TestFixtureSetUpAttribute" Inherited="true" />
+ <HasAttribute Name="NUnit.Framework.TestFixtureTearDownAttribute" Inherited="true" />
+ <HasAttribute Name="NUnit.Framework.OneTimeSetUpAttribute" Inherited="true" />
+ <HasAttribute Name="NUnit.Framework.OneTimeTearDownAttribute" Inherited="true" />
+ </Or>
+ </And>
+ </Entry.Match>
+ </Entry>
+
+ <Entry DisplayName="All other members" />
+
+ <Entry DisplayName="Test Methods" Priority="100">
+ <Entry.Match>
+ <And>
+ <Kind Is="Method" />
+ <HasAttribute Name="NUnit.Framework.TestAttribute" Inherited="false" />
+ <HasAttribute Name="NUnit.Framework.TestCaseAttribute" Inherited="false" />
+ <HasAttribute Name="NUnit.Framework.TestCaseSourceAttribute" Inherited="false" />
+ </And>
+ </Entry.Match>
+
+ <Entry.SortBy>
+ <Name />
+ </Entry.SortBy>
+ </Entry>
+ </TypePattern>
+
+ <TypePattern DisplayName="Default Pattern">
+ <Entry DisplayName="Public Delegates" Priority="100">
+ <Entry.Match>
+ <And>
+ <Access Is="Public" />
+ <Kind Is="Delegate" />
+ </And>
+ </Entry.Match>
+
+ <Entry.SortBy>
+ <Name />
+ </Entry.SortBy>
+ </Entry>
+
+ <Entry DisplayName="Public Enums" Priority="100">
+ <Entry.Match>
+ <And>
+ <Access Is="Public" />
+ <Kind Is="Enum" />
+ </And>
+ </Entry.Match>
+
+ <Entry.SortBy>
+ <Name />
+ </Entry.SortBy>
+ </Entry>
+
+ <Entry DisplayName="Static Fields and Constants">
+ <Entry.Match>
+ <Or>
+ <Kind Is="Constant" />
+ <And>
+ <Kind Is="Field" />
+ <Static />
+ </And>
+ </Or>
+ </Entry.Match>
+
+ <Entry.SortBy>
+ <Kind>
+ <Kind.Order>
+ <DeclarationKind>Constant</DeclarationKind>
+ <DeclarationKind>Field</DeclarationKind>
+ </Kind.Order>
+ </Kind>
+ </Entry.SortBy>
+ </Entry>
+
+ <Entry DisplayName="Fields">
+ <Entry.Match>
+ <And>
+ <Kind Is="Field" />
+ <Not>
+ <Static />
+ </Not>
+ </And>
+ </Entry.Match>
+
+ <Entry.SortBy>
+ <Readonly />
+ <Name />
+ </Entry.SortBy>
+ </Entry>
+
+ <Entry DisplayName="Events">
+ <Entry.Match>
+ <Kind Is="Event" />
+ </Entry.Match>
+
+ <Entry.SortBy>
+ <Name />
+ </Entry.SortBy>
+ </Entry>
+
+ <Entry DisplayName="Constructors">
+ <Entry.Match>
+ <Kind Is="Constructor" />
+ </Entry.Match>
+
+ <Entry.SortBy>
+ <Static/>
+ </Entry.SortBy>
+ </Entry>
+
+ <Entry DisplayName="Properties, Indexers">
+ <Entry.Match>
+ <Or>
+ <Kind Is="Property" />
+ <Kind Is="Indexer" />
+ </Or>
+ </Entry.Match>
+ </Entry>
+
+ <Entry DisplayName="Interface Implementations" Priority="100">
+ <Entry.Match>
+ <And>
+ <Kind Is="Member" />
+ <ImplementsInterface />
+ </And>
+ </Entry.Match>
+
+ <Entry.SortBy>
+ <ImplementsInterface Immediate="true" />
+ </Entry.SortBy>
+ </Entry>
+
+ <Entry DisplayName="All other members" />
+
+ <Entry DisplayName="Nested Types">
+ <Entry.Match>
+ <Kind Is="Type" />
+ </Entry.Match>
+ </Entry>
+ </TypePattern>
+</Patterns>
+
+ True
+ True
\ No newline at end of file
diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj
new file mode 100644
index 0000000..cccd5dc
--- /dev/null
+++ b/Tests/Tests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ Coder.Desktop.Tests
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tests/Vpn.Proto/ApiVersionTest.cs b/Tests/Vpn.Proto/ApiVersionTest.cs
new file mode 100644
index 0000000..3536bd2
--- /dev/null
+++ b/Tests/Vpn.Proto/ApiVersionTest.cs
@@ -0,0 +1,36 @@
+using Coder.Desktop.Vpn.Proto;
+
+namespace Coder.Desktop.Tests.Vpn.Proto;
+
+[TestFixture]
+public class ApiVersionTest
+{
+ [Test(Description = "Parse a variety of version strings")]
+ public void Parse()
+ {
+ Assert.That(ApiVersion.Parse("2.1"), Is.EqualTo(new ApiVersion(2, 1)));
+ Assert.That(ApiVersion.Parse("1.0"), Is.EqualTo(new ApiVersion(1, 0)));
+
+ Assert.Throws(() => ApiVersion.Parse("cats"));
+ Assert.Throws(() => ApiVersion.Parse("cats.dogs"));
+ Assert.Throws(() => ApiVersion.Parse("1.dogs"));
+ Assert.Throws(() => ApiVersion.Parse("1.0.1"));
+ Assert.Throws(() => ApiVersion.Parse("11"));
+ }
+
+ [Test(Description = "Test that versions are compatible")]
+ public void Validate()
+ {
+ var twoOne = new ApiVersion(2, 1, 1);
+ Assert.DoesNotThrow(() => twoOne.Validate(twoOne));
+ Assert.DoesNotThrow(() => twoOne.Validate(new ApiVersion(2, 0)));
+ Assert.DoesNotThrow(() => twoOne.Validate(new ApiVersion(1, 0)));
+
+ var ex = Assert.Throws(() => twoOne.Validate(new ApiVersion(2, 2)));
+ Assert.That(ex.Message, Does.Contain("Peer supports newer minor version"));
+ ex = Assert.Throws(() => twoOne.Validate(new ApiVersion(3, 1)));
+ Assert.That(ex.Message, Does.Contain("Peer supports newer major version"));
+ ex = Assert.Throws(() => twoOne.Validate(new ApiVersion(0, 8)));
+ Assert.That(ex.Message, Does.Contain("Version is no longer supported"));
+ }
+}
diff --git a/Tests/Vpn.Proto/RpcHeaderTest.cs b/Tests/Vpn.Proto/RpcHeaderTest.cs
new file mode 100644
index 0000000..17c8636
--- /dev/null
+++ b/Tests/Vpn.Proto/RpcHeaderTest.cs
@@ -0,0 +1,41 @@
+using System.Text;
+using Coder.Desktop.Vpn.Proto;
+
+namespace Coder.Desktop.Tests.Vpn.Proto;
+
+[TestFixture]
+public class RpcHeaderTest
+{
+ [Test(Description = "Parse and use some valid header strings")]
+ public void Valid()
+ {
+ var headerStr = "codervpn 2.1 manager";
+ var header = RpcHeader.Parse(headerStr);
+ Assert.That(header.Role.ToString(), Is.EqualTo(RpcRole.Manager));
+ Assert.That(header.Version, Is.EqualTo(new ApiVersion(2, 1)));
+ Assert.That(header.ToString(), Is.EqualTo(headerStr + "\n"));
+ Assert.That(header.ToBytes().ToArray(), Is.EqualTo(Encoding.UTF8.GetBytes(headerStr + "\n")));
+
+ headerStr = "codervpn 1.0 tunnel";
+ header = RpcHeader.Parse(headerStr);
+ Assert.That(header.Role.ToString(), Is.EqualTo(RpcRole.Tunnel));
+ Assert.That(header.Version, Is.EqualTo(new ApiVersion(1, 0)));
+ Assert.That(header.ToString(), Is.EqualTo(headerStr + "\n"));
+ Assert.That(header.ToBytes().ToArray(), Is.EqualTo(Encoding.UTF8.GetBytes(headerStr + "\n")));
+ }
+
+ [Test(Description = "Try to parse some invalid header strings")]
+ public void ParseInvalid()
+ {
+ var ex = Assert.Throws(() => RpcHeader.Parse("codervpn"));
+ Assert.That(ex.Message, Does.Contain("Wrong number of parts"));
+ ex = Assert.Throws(() => RpcHeader.Parse("codervpn 1.0 manager cats"));
+ Assert.That(ex.Message, Does.Contain("Wrong number of parts"));
+ ex = Assert.Throws(() => RpcHeader.Parse("codervpn 1.0"));
+ Assert.That(ex.Message, Does.Contain("Wrong number of parts"));
+ ex = Assert.Throws(() => RpcHeader.Parse("cats 1.0 manager"));
+ Assert.That(ex.Message, Does.Contain("Invalid preamble"));
+ ex = Assert.Throws(() => RpcHeader.Parse("codervpn 1.0 cats"));
+ Assert.That(ex.Message, Does.Contain("Unknown role 'cats'"));
+ }
+}
diff --git a/Tests/Vpn.Proto/RpcMessageTest.cs b/Tests/Vpn.Proto/RpcMessageTest.cs
new file mode 100644
index 0000000..36de12d
--- /dev/null
+++ b/Tests/Vpn.Proto/RpcMessageTest.cs
@@ -0,0 +1,39 @@
+using Coder.Desktop.Vpn.Proto;
+
+namespace Coder.Desktop.Tests.Vpn.Proto;
+
+[TestFixture]
+public class RpcRoleAttributeTest
+{
+ [Test]
+ public void Valid()
+ {
+ var role = new RpcRoleAttribute(RpcRole.Manager);
+ Assert.That(role.Role.ToString(), Is.EqualTo(RpcRole.Manager));
+ role = new RpcRoleAttribute(RpcRole.Tunnel);
+ Assert.That(role.Role.ToString(), Is.EqualTo(RpcRole.Tunnel));
+ }
+
+ [Test]
+ public void Invalid()
+ {
+ Assert.Throws(() => _ = new RpcRoleAttribute("cats"));
+ }
+}
+
+[TestFixture]
+public class RpcMessageTest
+{
+ [Test]
+ public void GetRole()
+ {
+ // RpcMessage is not a supported message type and doesn't have an
+ // RpcRoleAttribute
+ var ex = Assert.Throws(() => _ = RpcMessage.GetRole());
+ Assert.That(ex.Message,
+ Does.Contain("Message type 'Coder.Desktop.Vpn.Proto.RPC' does not have a RpcRoleAttribute"));
+
+ Assert.That(ManagerMessage.GetRole().ToString(), Is.EqualTo(RpcRole.Manager));
+ Assert.That(TunnelMessage.GetRole().ToString(), Is.EqualTo(RpcRole.Tunnel));
+ }
+}
diff --git a/Tests/Vpn.Proto/RpcRoleTest.cs b/Tests/Vpn.Proto/RpcRoleTest.cs
new file mode 100644
index 0000000..f39d5cb
--- /dev/null
+++ b/Tests/Vpn.Proto/RpcRoleTest.cs
@@ -0,0 +1,22 @@
+using Coder.Desktop.Vpn.Proto;
+
+namespace Coder.Desktop.Tests.Vpn.Proto;
+
+[TestFixture]
+public class RpcRoleTest
+{
+ [Test(Description = "Instantiate a RpcRole with a valid name")]
+ public void ValidRole()
+ {
+ var role = new RpcRole(RpcRole.Manager);
+ Assert.That(role.ToString(), Is.EqualTo(RpcRole.Manager));
+ role = new RpcRole(RpcRole.Tunnel);
+ Assert.That(role.ToString(), Is.EqualTo(RpcRole.Tunnel));
+ }
+
+ [Test(Description = "Try to instantiate a RpcRole with an invalid name")]
+ public void InvalidRole()
+ {
+ Assert.Throws(() => _ = new RpcRole("cats"));
+ }
+}
diff --git a/Tests/Vpn/SerdesTest.cs b/Tests/Vpn/SerdesTest.cs
new file mode 100644
index 0000000..7673d6a
--- /dev/null
+++ b/Tests/Vpn/SerdesTest.cs
@@ -0,0 +1,89 @@
+using System.Buffers.Binary;
+using Coder.Desktop.Vpn;
+using Coder.Desktop.Vpn.Proto;
+using Google.Protobuf;
+
+namespace Coder.Desktop.Tests.Vpn;
+
+[TestFixture]
+public class SerdesTest
+{
+ [Test(Description = "Tests that writing and reading a message works")]
+ [Timeout(5_000)]
+ public async Task WriteReadMessage()
+ {
+ var (stream1, stream2) = BidirectionalPipe.New();
+ var serdes = new Serdes();
+
+ var msg = new ManagerMessage
+ {
+ Start = new StartRequest(),
+ };
+ await serdes.WriteMessage(stream1, msg);
+ var got = await serdes.ReadMessage(stream2);
+ Assert.That(msg, Is.EqualTo(got));
+ }
+
+ [Test(Description = "Tests that writing a message larger than 16 MiB throws an exception")]
+ [Timeout(5_000)]
+ public void WriteMessageTooLarge()
+ {
+ var (stream1, _) = BidirectionalPipe.New();
+ var serdes = new Serdes();
+
+ var msg = new ManagerMessage
+ {
+ Start = new StartRequest
+ {
+ ApiToken = new string('a', 0x1000001),
+ CoderUrl = "test",
+ },
+ };
+ Assert.ThrowsAsync(() => serdes.WriteMessage(stream1, msg));
+ }
+
+ [Test(Description = "Tests that attempting to read a message larger than 16 MiB throws an exception")]
+ [Timeout(5_000)]
+ public async Task ReadMessageTooLarge()
+ {
+ var (stream1, stream2) = BidirectionalPipe.New();
+ var serdes = new Serdes();
+
+ // In this test we don't actually write a message as the parser should
+ // bail out immediately after reading the message length
+ var lenBytes = new byte[4];
+ BinaryPrimitives.WriteUInt32BigEndian(lenBytes, 0x1000001);
+ await stream1.WriteAsync(lenBytes);
+ Assert.ThrowsAsync(() => serdes.ReadMessage(stream2));
+ }
+
+ [Test(Description = "Read an empty (size 0) message from the stream")]
+ [Timeout(5_000)]
+ public async Task ReadEmptyMessage()
+ {
+ var (stream1, stream2) = BidirectionalPipe.New();
+ var serdes = new Serdes();
+
+ // Write an empty message.
+ var lenBytes = new byte[4];
+ BinaryPrimitives.WriteUInt32BigEndian(lenBytes, 0);
+ await stream1.WriteAsync(lenBytes);
+ var ex = Assert.ThrowsAsync(() => serdes.ReadMessage(stream2));
+ Assert.That(ex.Message, Does.Contain("Received message size 0"));
+ }
+
+ [Test(Description = "Read an invalid/corrupt message from the stream")]
+ [Timeout(5_000)]
+ public async Task ReadInvalidMessage()
+ {
+ var (stream1, stream2) = BidirectionalPipe.New();
+ var serdes = new Serdes();
+
+ var lenBytes = new byte[4];
+ BinaryPrimitives.WriteUInt32BigEndian(lenBytes, 1);
+ await stream1.WriteAsync(lenBytes);
+ await stream1.WriteAsync(new byte[1]);
+ var ex = Assert.ThrowsAsync(() => serdes.ReadMessage(stream2));
+ Assert.That(ex.InnerException, Is.TypeOf(typeof(InvalidProtocolBufferException)));
+ }
+}
diff --git a/Tests/Vpn/SpeakerTest.cs b/Tests/Vpn/SpeakerTest.cs
new file mode 100644
index 0000000..3eeebb3
--- /dev/null
+++ b/Tests/Vpn/SpeakerTest.cs
@@ -0,0 +1,391 @@
+using System.Buffers;
+using System.IO.Pipelines;
+using System.Reflection;
+using System.Threading.Channels;
+using Coder.Desktop.Vpn;
+using Coder.Desktop.Vpn.Proto;
+
+namespace Coder.Desktop.Tests.Vpn;
+
+#region BidrectionalPipe
+
+internal class BidirectionalPipe(PipeReader reader, PipeWriter writer) : Stream
+{
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => true;
+ public override long Length => -1;
+
+ public override long Position
+ {
+ get => -1;
+ set => throw new NotImplementedException("BidirectionalPipe does not support setting position");
+ }
+
+ public static (BidirectionalPipe, BidirectionalPipe) New()
+ {
+ var pipe1 = new Pipe();
+ var pipe2 = new Pipe();
+ return (new BidirectionalPipe(pipe1.Reader, pipe2.Writer), new BidirectionalPipe(pipe2.Reader, pipe1.Writer));
+ }
+
+ public override void Flush()
+ {
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ return ReadAsync(buffer, offset, count).GetAwaiter().GetResult();
+ }
+
+ public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct)
+ {
+ var result = await reader.ReadAtLeastAsync(1, ct);
+ var n = Math.Min((int)result.Buffer.Length, count);
+ // Copy result.Buffer[0:n] to buffer[offset:offset+n]
+ result.Buffer.Slice(0, n).CopyTo(buffer.AsMemory(offset, n).Span);
+ if (!result.IsCompleted) reader.AdvanceTo(result.Buffer.GetPosition(n));
+ return n;
+ }
+
+ public override long Seek(long offset, SeekOrigin origin)
+ {
+ throw new NotImplementedException("BidirectionalPipe does not support seeking");
+ }
+
+ public override void SetLength(long value)
+ {
+ throw new NotImplementedException("BidirectionalPipe does not support setting length");
+ }
+
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ WriteAsync(buffer, offset, count).GetAwaiter().GetResult();
+ }
+
+ public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct)
+ {
+ await writer.WriteAsync(buffer.AsMemory(offset, count), ct);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ writer.Complete();
+ reader.Complete();
+ }
+}
+
+#endregion
+
+#region FailableStream
+
+internal class FailableStream : Stream
+{
+ private readonly Stream _inner;
+ private readonly TaskCompletionSource _readTcs = new();
+
+ private readonly TaskCompletionSource _writeTcs = new();
+
+ public FailableStream(Stream inner, Exception? writeException, Exception? readException)
+ {
+ _inner = inner;
+ if (writeException != null) _writeTcs.SetException(writeException);
+ if (readException != null) _readTcs.SetException(readException);
+ }
+
+ public override bool CanRead => _inner.CanRead;
+ public override bool CanSeek => _inner.CanSeek;
+ public override bool CanWrite => _inner.CanWrite;
+ public override long Length => _inner.Length;
+
+ public override long Position
+ {
+ get => _inner.Position;
+ set => _inner.Position = value;
+ }
+
+ public void SetWriteException(Exception ex)
+ {
+ _writeTcs.SetException(ex);
+ }
+
+ public void SetReadException(Exception ex)
+ {
+ _readTcs.SetException(ex);
+ }
+
+ public override void Flush()
+ {
+ _inner.Flush();
+ }
+
+ public override long Seek(long offset, SeekOrigin origin)
+ {
+ return _inner.Seek(offset, origin);
+ }
+
+ public override void SetLength(long value)
+ {
+ _inner.SetLength(value);
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ return _inner.ReadAsync(buffer, offset, count).GetAwaiter().GetResult();
+ }
+
+ private void CheckException(TaskCompletionSource tcs)
+ {
+ if (tcs.Task.IsFaulted) throw tcs.Task.Exception.InnerException!;
+ }
+
+ public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default)
+ {
+ CheckException(_readTcs);
+ var readTask = _inner.ReadAsync(buffer, cancellationToken);
+ await Task.WhenAny(readTask.AsTask(), _readTcs.Task);
+ CheckException(_readTcs);
+ return await readTask;
+ }
+
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ _inner.WriteAsync(buffer, offset, count).Wait();
+ }
+
+ public override async ValueTask WriteAsync(ReadOnlyMemory buffer,
+ CancellationToken cancellationToken = default)
+ {
+ CheckException(_writeTcs);
+ var writeTask = _inner.WriteAsync(buffer, cancellationToken);
+ await Task.WhenAny(writeTask.AsTask(), _writeTcs.Task);
+ CheckException(_writeTcs);
+ await writeTask;
+ }
+}
+
+#endregion
+
+[TestFixture]
+public class SpeakerTest
+{
+ [Test(Description = "Send a message from speaker1 to speaker2, receive it, and send a reply back")]
+ [Timeout(30_000)]
+ public async Task SendReceiveReplyReceive()
+ {
+ var (stream1, stream2) = BidirectionalPipe.New();
+
+ await using var speaker1 = new Speaker(stream1);
+ var speaker1Ch = Channel
+ .CreateUnbounded>();
+ speaker1.Receive += msg => { Assert.That(speaker1Ch.Writer.TryWrite(msg), Is.True); };
+ speaker1.Error += ex => { Assert.Fail($"speaker1 error: {ex}"); };
+
+ await using var speaker2 = new Speaker(stream2);
+ var speaker2Ch = Channel
+ .CreateUnbounded>();
+ speaker2.Receive += msg => { Assert.That(speaker2Ch.Writer.TryWrite(msg), Is.True); };
+ speaker2.Error += ex => { Assert.Fail($"speaker2 error: {ex}"); };
+
+ // Start both speakers simultaneously
+ Task.WaitAll(speaker1.StartAsync(), speaker2.StartAsync());
+
+ // Send a normal message from speaker2 to speaker1
+ await speaker2.SendMessage(new TunnelMessage
+ {
+ PeerUpdate = new PeerUpdate(),
+ });
+ var receivedMessage = await speaker1Ch.Reader.ReadAsync();
+ Assert.That(receivedMessage.RpcField, Is.Null); // not a request
+ Assert.That(receivedMessage.Message.PeerUpdate, Is.Not.Null);
+
+ // Send a message from speaker1 to speaker2 in the background
+ var sendTask = speaker1.SendRequestAwaitReply(new ManagerMessage
+ {
+ Start = new StartRequest
+ {
+ ApiToken = "test",
+ CoderUrl = "test",
+ },
+ });
+
+ // Receive the message in speaker2
+ var message = await speaker2Ch.Reader.ReadAsync();
+ Assert.That(message.RpcField, Is.Not.Null);
+ Assert.That(message.RpcField!.MsgId, Is.Not.EqualTo(0));
+ Assert.That(message.RpcField!.ResponseTo, Is.EqualTo(0));
+ Assert.That(message.Message.Start.ApiToken, Is.EqualTo("test"));
+
+ // Send a reply back to speaker1
+ await message.SendReply(new TunnelMessage
+ {
+ Start = new StartResponse
+ {
+ Success = true,
+ },
+ });
+
+ // Receive the reply in speaker1 by awaiting sendTask
+ var reply = await sendTask;
+ Assert.That(message.RpcField, Is.Not.Null);
+ Assert.That(reply.RpcField!.MsgId, Is.EqualTo(0));
+ Assert.That(reply.RpcField!.ResponseTo, Is.EqualTo(message.RpcField!.MsgId));
+ Assert.That(reply.Message.Start.Success, Is.True);
+ }
+
+ [Test(Description = "Encounter a write error during handshake")]
+ [Timeout(30_000)]
+ public async Task WriteError()
+ {
+ var (stream1, _) = BidirectionalPipe.New();
+ var writeEx = new IOException("Test write error");
+ var failStream = new FailableStream(stream1, writeEx, null);
+
+ await using var speaker = new Speaker(failStream);
+
+ var gotEx = Assert.ThrowsAsync(() => speaker.StartAsync());
+ Assert.That(gotEx, Is.EqualTo(writeEx));
+ }
+
+ [Test(Description = "Encounter a read error during handshake")]
+ [Timeout(30_000)]
+ public async Task ReadError()
+ {
+ var (stream1, _) = BidirectionalPipe.New();
+ var readEx = new IOException("Test read error");
+ var failStream = new FailableStream(stream1, null, readEx);
+
+ await using var speaker = new Speaker(failStream);
+
+ var gotEx = Assert.ThrowsAsync(() => speaker.StartAsync());
+ Assert.That(gotEx, Is.EqualTo(readEx));
+ }
+
+ [Test(Description = "Receive a header that exceeds 256 bytes")]
+ [Timeout(30_000)]
+ public async Task ReadLargeHeader()
+ {
+ var (stream1, stream2) = BidirectionalPipe.New();
+ await using var speaker1 = new Speaker(stream1);
+
+ var header = new byte[257];
+ for (var i = 0; i < header.Length; i++) header[i] = (byte)'a';
+ await stream2.WriteAsync(header);
+
+ var gotEx = Assert.ThrowsAsync(() => speaker1.StartAsync());
+ Assert.That(gotEx.Message, Does.Contain("Header malformed or too large"));
+ }
+
+ [Test(Description = "Encounter a write error during message send")]
+ [Timeout(30_000)]
+ public async Task SendMessageWriteError()
+ {
+ var (stream1, stream2) = BidirectionalPipe.New();
+ var failStream = new FailableStream(stream1, null, null);
+
+ await using var speaker1 = new Speaker(failStream);
+ speaker1.Receive += msg => Assert.Fail($"speaker1 received message: {msg}");
+ speaker1.Error += ex => Assert.Fail($"speaker1 error: {ex}");
+ await using var speaker2 = new Speaker(stream2);
+ speaker2.Receive += msg => Assert.Fail($"speaker2 received message: {msg}");
+ speaker2.Error += ex => Assert.Fail($"speaker2 error: {ex}");
+ await Task.WhenAll(speaker1.StartAsync(), speaker2.StartAsync());
+
+ var writeEx = new IOException("Test write error");
+ failStream.SetWriteException(writeEx);
+
+ var gotEx = Assert.ThrowsAsync(() => speaker1.SendMessage(new ManagerMessage
+ {
+ Start = new StartRequest(),
+ }));
+ Assert.That(gotEx, Is.EqualTo(writeEx));
+ }
+
+ [Test(Description = "Encounter a read error during message receive")]
+ [Timeout(30_000)]
+ public async Task ReceiveMessageReadError()
+ {
+ var (stream1, stream2) = BidirectionalPipe.New();
+ var failStream = new FailableStream(stream1, null, null);
+
+ // Speaker1 is bound to failStream and will write an error to errorCh
+ var errorCh = Channel.CreateUnbounded();
+ await using var speaker1 = new Speaker(failStream);
+ speaker1.Receive += msg => Assert.Fail($"speaker1 received message: {msg}");
+ speaker1.Error += ex => errorCh.Writer.TryWrite(ex);
+
+ // Speaker2 is normal and is only used to perform a handshake
+ await using var speaker2 = new Speaker(stream2);
+ speaker2.Receive += msg => Assert.Fail($"speaker2 received message: {msg}");
+ speaker2.Error += ex => Assert.Fail($"speaker2 error: {ex}");
+ await Task.WhenAll(speaker1.StartAsync(), speaker2.StartAsync());
+
+ // Now the handshake is complete, cause all reads to fail
+ var readEx = new IOException("Test write error");
+ failStream.SetReadException(readEx);
+
+ var gotEx = await errorCh.Reader.ReadAsync();
+ Assert.That(gotEx, Is.EqualTo(readEx));
+
+ // The receive loop should be stopped within a timely fashion.
+ var receiveLoopTask = (Task?)speaker1.GetType()
+ .GetField("_receiveTask", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(speaker1);
+ if (receiveLoopTask is null)
+ {
+ Assert.Fail("Receive loop task not found");
+ }
+ else
+ {
+ var delayTask = Task.Delay(TimeSpan.FromSeconds(5));
+ await Task.WhenAny(receiveLoopTask, delayTask);
+ Assert.That(receiveLoopTask.IsCompleted, Is.True);
+ }
+ }
+
+ [Test(Description = "Handle dispose while receive loop is running")]
+ [Timeout(30_000)]
+ public async Task DisposeWhileReceiveLoopRunning()
+ {
+ var (stream1, stream2) = BidirectionalPipe.New();
+ var speaker1 = new Speaker(stream1);
+ await using var speaker2 = new Speaker(stream2);
+ await Task.WhenAll(speaker1.StartAsync(), speaker2.StartAsync());
+
+ // Dispose should happen in a timely fashion
+ var disposeTask = speaker1.DisposeAsync();
+ var delayTask = Task.Delay(TimeSpan.FromSeconds(5));
+ await Task.WhenAny(disposeTask.AsTask(), delayTask);
+ Assert.That(disposeTask.IsCompleted, Is.True);
+
+ // Receive loop should be stopped
+ var receiveLoopTask = (Task?)speaker1.GetType()
+ .GetField("_receiveTask", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(speaker1);
+ if (receiveLoopTask is null)
+ Assert.Fail("Receive loop task not found");
+ else
+ Assert.That(receiveLoopTask.IsCompleted, Is.True);
+ }
+
+ [Test(Description = "Handle dispose while a message is awaiting a reply")]
+ [Timeout(30_000)]
+ public async Task DisposeWhileAwaitingReply()
+ {
+ var (stream1, stream2) = BidirectionalPipe.New();
+ var speaker1 = new Speaker(stream1);
+ await using var speaker2 = new Speaker(stream2);
+ await Task.WhenAll(speaker1.StartAsync(), speaker2.StartAsync());
+
+ // Send a message from speaker1 to speaker2
+ var sendTask = speaker1.SendRequestAwaitReply(new ManagerMessage
+ {
+ Start = new StartRequest(),
+ });
+
+ // Dispose speaker1
+ await speaker1.DisposeAsync();
+
+ // The send task should complete with an exception
+ Assert.ThrowsAsync(() => sendTask.AsTask());
+ }
+}
diff --git a/Vpn.Proto/ApiVersion.cs b/Vpn.Proto/ApiVersion.cs
new file mode 100644
index 0000000..25d96f9
--- /dev/null
+++ b/Vpn.Proto/ApiVersion.cs
@@ -0,0 +1,103 @@
+namespace Coder.Desktop.Vpn.Proto;
+
+///
+/// Thrown when the two peers are incompatible with each other.
+///
+public class ApiCompatibilityException(ApiVersion localVersion, ApiVersion remoteVersion, string message)
+ : Exception($"{message}: local={localVersion}, remote={remoteVersion}");
+
+///
+/// A version of the RPC API. Can be compared other versions to determine compatibility between two peers.
+///
+/// The major version of the peer
+/// The minor version of the peer
+/// Additional supported major versions of the peer
+public class ApiVersion(int major, int minor, params int[] additionalMajors)
+{
+ public static readonly ApiVersion Current = new(1, 0);
+
+ private int Major { get; } = major;
+ private int Minor { get; } = minor;
+ private int[] AdditionalMajors { get; } = additionalMajors;
+
+ ///
+ /// Parse a string in the format "major.minor" into an ApiVersion.
+ ///
+ /// Version string to parse
+ /// Parsed ApiVersion
+ /// The version string is invalid
+ public static ApiVersion Parse(string versionString)
+ {
+ var parts = versionString.Split('.');
+ if (parts.Length != 2) throw new ArgumentException($"Invalid version string '{versionString}'");
+
+ try
+ {
+ var major = int.Parse(parts[0]);
+ var minor = int.Parse(parts[1]);
+ return new ApiVersion(major, minor);
+ }
+ catch (FormatException e)
+ {
+ throw new ArgumentException($"Invalid version string '{versionString}'", e);
+ }
+ }
+
+ public override string ToString()
+ {
+ return $"{Major}.{Minor}";
+ }
+
+ ///
+ /// Validate that this version is compatible with another version. If the other version is not compatible, an exception
+ /// is thrown.
+ ///
+ /// Version to compare against
+ /// The two peers have incompatible versions
+ public void Validate(ApiVersion other)
+ {
+ if (other.Major > Major) throw new ApiCompatibilityException(this, other, "Peer supports newer major version");
+ if (other.Major == Major)
+ {
+ if (other.Minor > Minor)
+ throw new ApiCompatibilityException(this, other, "Peer supports newer minor version");
+
+ return;
+ }
+
+ if (AdditionalMajors.Any(major => other.Major == major)) return;
+ throw new ApiCompatibilityException(this, other, "Version is no longer supported");
+ }
+
+ #region ApiVersion Equality
+
+ public static bool operator ==(ApiVersion a, ApiVersion b)
+ {
+ return a.Equals(b);
+ }
+
+ public static bool operator !=(ApiVersion a, ApiVersion b)
+ {
+ return !a.Equals(b);
+ }
+
+ private bool Equals(ApiVersion other)
+ {
+ return Major == other.Major && Minor == other.Minor && AdditionalMajors.SequenceEqual(other.AdditionalMajors);
+ }
+
+ public override bool Equals(object? obj)
+ {
+ if (obj is null) return false;
+ if (ReferenceEquals(this, obj)) return true;
+ if (obj.GetType() != GetType()) return false;
+ return Equals((ApiVersion)obj);
+ }
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(Major, Minor, AdditionalMajors);
+ }
+
+ #endregion
+}
diff --git a/Vpn.Proto/RpcHeader.cs b/Vpn.Proto/RpcHeader.cs
new file mode 100644
index 0000000..0aa63ae
--- /dev/null
+++ b/Vpn.Proto/RpcHeader.cs
@@ -0,0 +1,46 @@
+using System.Text;
+
+namespace Coder.Desktop.Vpn.Proto;
+
+///
+/// A header to write or read from a stream to identify the speaker's role and version.
+///
+/// Role of the speaker
+/// Version of the speaker
+public class RpcHeader(RpcRole role, ApiVersion version)
+{
+ private const string Preamble = "codervpn";
+
+ public RpcRole Role { get; } = role;
+ public ApiVersion Version { get; } = version;
+
+ ///
+ /// Parse a header string into a SpeakerHeader .
+ ///
+ /// Raw header string without trailing newline
+ /// Parsed header
+ /// Invalid header string
+ public static RpcHeader Parse(string header)
+ {
+ var parts = header.Split(' ');
+ if (parts.Length != 3) throw new ArgumentException($"Wrong number of parts in header string '{header}'");
+ if (parts[0] != Preamble) throw new ArgumentException($"Invalid preamble in header string '{header}'");
+
+ var version = ApiVersion.Parse(parts[1]);
+ var role = new RpcRole(parts[2]);
+ return new RpcHeader(role, version);
+ }
+
+ ///
+ /// Construct a header string from the role and version with a trailing newline.
+ ///
+ public override string ToString()
+ {
+ return $"{Preamble} {Version} {Role}\n";
+ }
+
+ public ReadOnlyMemory ToBytes()
+ {
+ return Encoding.UTF8.GetBytes(ToString());
+ }
+}
diff --git a/Vpn.Proto/RpcMessage.cs b/Vpn.Proto/RpcMessage.cs
new file mode 100644
index 0000000..c44168c
--- /dev/null
+++ b/Vpn.Proto/RpcMessage.cs
@@ -0,0 +1,82 @@
+using System.Reflection;
+using Google.Protobuf;
+
+namespace Coder.Desktop.Vpn.Proto;
+
+[AttributeUsage(AttributeTargets.Class, Inherited = false)]
+public class RpcRoleAttribute(string role) : Attribute
+{
+ public RpcRole Role { get; } = new(role);
+}
+
+///
+/// Represents an actual over-the-wire message type.
+///
+/// Protobuf message type
+public abstract class RpcMessage where T : IMessage
+{
+ ///
+ /// The inner RPC component of the message. This is a separate field as the C# compiler does not allow the existing Rpc
+ /// field to be overridden or implement this abstract property.
+ ///
+ public abstract RPC? RpcField { get; set; }
+
+ ///
+ /// The inner message component of the message. This exists so values of type RpcMessage can easily get message
+ /// contents.
+ ///
+ public abstract T Message { get; }
+
+ ///
+ /// Check if the message is valid. Checks for empty oneof of fields.
+ ///
+ /// Invalid message
+ public abstract void Validate();
+
+ ///
+ /// Gets the RpcRole of the message type from it's RpcRole attribute.
+ ///
+ ///
+ /// The message type does not have an RpcRoleAttribute
+ public static RpcRole GetRole()
+ {
+ var type = typeof(T);
+ var attr = type.GetCustomAttribute();
+ if (attr is null) throw new ArgumentException($"Message type '{type}' does not have a RpcRoleAttribute");
+ return attr.Role;
+ }
+}
+
+[RpcRole(RpcRole.Manager)]
+public partial class ManagerMessage : RpcMessage
+{
+ public override RPC? RpcField
+ {
+ get => Rpc;
+ set => Rpc = value;
+ }
+
+ public override ManagerMessage Message => this;
+
+ public override void Validate()
+ {
+ if (MsgCase == MsgOneofCase.None) throw new ArgumentException("Message does not contain inner message type");
+ }
+}
+
+[RpcRole(RpcRole.Tunnel)]
+public partial class TunnelMessage : RpcMessage
+{
+ public override RPC? RpcField
+ {
+ get => Rpc;
+ set => Rpc = value;
+ }
+
+ public override TunnelMessage Message => this;
+
+ public override void Validate()
+ {
+ if (MsgCase == MsgOneofCase.None) throw new ArgumentException("Message does not contain inner message type");
+ }
+}
diff --git a/Vpn.Proto/RpcRole.cs b/Vpn.Proto/RpcRole.cs
new file mode 100644
index 0000000..9190281
--- /dev/null
+++ b/Vpn.Proto/RpcRole.cs
@@ -0,0 +1,56 @@
+namespace Coder.Desktop.Vpn.Proto;
+
+///
+/// Represents a role that either side of the connection can fulfil.
+///
+public sealed class RpcRole
+{
+ public const string Manager = "manager";
+ public const string Tunnel = "tunnel";
+
+ public RpcRole(string role)
+ {
+ if (role != Manager && role != Tunnel) throw new ArgumentException($"Unknown role '{role}'");
+
+ Role = role;
+ }
+
+ private string Role { get; }
+
+ public override string ToString()
+ {
+ return Role;
+ }
+
+ #region SpeakerRole equality
+
+ public static bool operator ==(RpcRole a, RpcRole b)
+ {
+ return a.Equals(b);
+ }
+
+ public static bool operator !=(RpcRole a, RpcRole b)
+ {
+ return !a.Equals(b);
+ }
+
+ private bool Equals(RpcRole other)
+ {
+ return Role == other.Role;
+ }
+
+ public override bool Equals(object? obj)
+ {
+ if (obj is null) return false;
+ if (ReferenceEquals(this, obj)) return true;
+ if (obj.GetType() != GetType()) return false;
+ return Equals((RpcRole)obj);
+ }
+
+ public override int GetHashCode()
+ {
+ return Role.GetHashCode();
+ }
+
+ #endregion
+}
diff --git a/Vpn.Proto/Vpn.Proto.csproj b/Vpn.Proto/Vpn.Proto.csproj
new file mode 100644
index 0000000..5380bd4
--- /dev/null
+++ b/Vpn.Proto/Vpn.Proto.csproj
@@ -0,0 +1,22 @@
+
+
+
+ Coder.Desktop.Vpn.Proto
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
diff --git a/Vpn.Proto/vpn.proto b/Vpn.Proto/vpn.proto
new file mode 100644
index 0000000..33a3ff4
--- /dev/null
+++ b/Vpn.Proto/vpn.proto
@@ -0,0 +1,198 @@
+syntax = "proto3";
+option go_package = "github.com/coder/coder/v2/vpn";
+option csharp_namespace = "Coder.Desktop.Vpn.Proto";
+
+import "google/protobuf/timestamp.proto";
+
+package vpn;
+
+// The CoderVPN protocol operates over a bidirectional stream between a "manager" and a "tunnel."
+// The manager is part of the Coder Desktop application and written in OS native code. It handles
+// configuring the VPN and displaying status to the end user. The tunnel is written in Go and
+// handles operating the actual tunnel, including reading and writing packets, & communicating with
+// the Coder server control plane.
+
+
+// RPC allows a very simple unary request/response RPC mechanism. The requester generates a unique
+// 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;
+}
+
+// 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;
+ }
+}
+
+// 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;
+ }
+}
+
+// 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;
+}
+
+// GetPeerUpdate asks for a PeerUpdate with a full set of data.
+message GetPeerUpdate {}
+
+// PeerUpdate is an update about workspaces and agents connected via the tunnel. It is generated in
+// 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;
+}
+
+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;
+}
+
+message Agent {
+ bytes id = 1; // UUID
+ string name = 2;
+ bytes workspace_id = 3; // UUID
+ 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;
+}
+
+// NetworkSettingsResponse is the response from the manager to the tunnel for a
+// NetworkSettingsRequest
+message NetworkSettingsResponse {
+ 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;
+}
+
+message StartResponse {
+ bool success = 1;
+ string error_message = 2;
+}
+
+// StopRequest is a request from the manager 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;
+}
diff --git a/Vpn/Serdes.cs b/Vpn/Serdes.cs
new file mode 100644
index 0000000..317417b
--- /dev/null
+++ b/Vpn/Serdes.cs
@@ -0,0 +1,104 @@
+using System.Buffers.Binary;
+using Coder.Desktop.Vpn.Proto;
+using Google.Protobuf;
+
+namespace Coder.Desktop.Vpn;
+
+///
+/// RaiiSemaphoreSlim is a wrapper around SemaphoreSlim that provides RAII-style locking.
+///
+internal class RaiiSemaphoreSlim(int initialCount, int maxCount)
+{
+ private readonly SemaphoreSlim _semaphore = new(initialCount, maxCount);
+
+ public async ValueTask LockAsync(CancellationToken ct = default)
+ {
+ await _semaphore.WaitAsync(ct);
+ return new Lock(_semaphore);
+ }
+
+ private class Lock(SemaphoreSlim semaphore) : IDisposable
+ {
+ public void Dispose()
+ {
+ semaphore.Release();
+ GC.SuppressFinalize(this);
+ }
+ }
+}
+
+///
+/// Serdes provides serialization and deserialization of messages read from a Stream.
+///
+public class Serdes
+ where TS : RpcMessage, IMessage
+ where TR : RpcMessage, IMessage , new()
+{
+ private const int MaxMessageSize = 0x1000000; // 16MiB
+
+ private readonly MessageParser _parser = new(() => new TR());
+
+ private readonly RaiiSemaphoreSlim _readLock = new(1, 1);
+ private readonly RaiiSemaphoreSlim _writeLock = new(1, 1);
+
+ ///
+ /// Encodes and writes a message to the Stream. Only one message will be written at a time.
+ ///
+ /// Stream to write the encoded message to
+ /// Message to encode and write
+ /// Optional cancellation token
+ /// If the message is invalid
+ public async Task WriteMessage(Stream conn, TS message, CancellationToken ct = default)
+ {
+ message.Validate(); // throws ArgumentException if invalid
+ using var _ = await _writeLock.LockAsync(ct);
+
+ var mb = message.ToByteArray();
+ if (mb == null || mb.Length == 0)
+ throw new ArgumentException("Marshalled message is empty");
+ if (mb.Length > MaxMessageSize)
+ throw new ArgumentException($"Marshalled message size {mb.Length} exceeds maximum {MaxMessageSize}");
+
+ var lenBytes = new byte[sizeof(uint)];
+ BinaryPrimitives.WriteUInt32BigEndian(lenBytes, (uint)mb.Length);
+ await conn.WriteAsync(lenBytes, ct);
+ await conn.WriteAsync(mb, ct);
+ }
+
+ ///
+ /// Reads a decodes a single message from the stream. Only one message will be read at a time.
+ ///
+ /// Stream to read the message from
+ /// Optional cancellation token
+ /// Decoded message
+ /// Could not decode the message
+ /// The message is invalid
+ public async Task ReadMessage(Stream conn, CancellationToken ct = default)
+ {
+ using var _ = await _readLock.LockAsync(ct);
+
+ var lenBytes = new byte[sizeof(uint)];
+ await conn.ReadExactlyAsync(lenBytes, ct);
+ var len = BinaryPrimitives.ReadUInt32BigEndian(lenBytes);
+ if (len == 0)
+ throw new IOException("Received message size 0");
+ if (len > MaxMessageSize)
+ throw new IOException($"Received message size {len} exceeds maximum {MaxMessageSize}");
+
+ var msgBytes = new byte[len];
+ await conn.ReadExactlyAsync(msgBytes, ct);
+
+ try
+ {
+ var msg = _parser.ParseFrom(msgBytes);
+ if (msg is null)
+ throw new IOException("Parsed message is null");
+ msg.Validate(); // throws ArgumentException if invalid
+ return msg;
+ }
+ catch (Exception e)
+ {
+ throw new IOException("Failed to parse message", e);
+ }
+ }
+}
diff --git a/Vpn/Speaker.cs b/Vpn/Speaker.cs
new file mode 100644
index 0000000..030f908
--- /dev/null
+++ b/Vpn/Speaker.cs
@@ -0,0 +1,254 @@
+using System.Collections.Concurrent;
+using System.Text;
+using Coder.Desktop.Vpn.Proto;
+using Coder.Desktop.Vpn.Utilities;
+using Google.Protobuf;
+
+namespace Coder.Desktop.Vpn;
+
+///
+/// Wraps a RpcMessage to allow easily sending a reply via the Speaker .
+///
+/// Speaker to use for sending reply
+/// Original received message
+public class ReplyableRpcMessage(Speaker speaker, TR message) : RpcMessage
+ where TS : RpcMessage, IMessage
+ where TR : RpcMessage, IMessage , new()
+{
+ public override RPC? RpcField
+ {
+ get => message.RpcField;
+ set => message.RpcField = value;
+ }
+
+ public override TR Message => message;
+
+ public override void Validate()
+ {
+ message.Validate();
+ }
+
+ ///
+ /// Sends a reply to the original message.
+ ///
+ /// Correct reply message
+ /// Optional cancellation token
+ public async Task SendReply(TS reply, CancellationToken ct = default)
+ {
+ await speaker.SendReply(message, reply, ct);
+ }
+}
+
+///
+/// Manages an RPC connection between two peers, allowing messages to be sent and received.
+///
+/// The message type for sent messages
+/// The message type for received messages
+public class Speaker : IAsyncDisposable
+ where TS : RpcMessage, IMessage
+ where TR : RpcMessage, IMessage , new()
+{
+ public delegate void OnErrorDelegate(Exception e);
+
+ public delegate void OnReceiveDelegate(ReplyableRpcMessage message);
+
+ private readonly Stream _conn;
+
+ // _cts is cancelled when Dispose is called and will cause all ongoing I/O
+ // operations to be cancelled.
+ private readonly CancellationTokenSource _cts = new();
+ private readonly ConcurrentDictionary> _pendingReplies = new();
+ private readonly Serdes _serdes = new();
+
+ // _lastRequestId is incremented using an atomic operation, and as such the
+ // first request ID will actually be 1.
+ private ulong _lastRequestId;
+ private Task? _receiveTask;
+
+ ///
+ /// Event that is triggered when an error occurs. The handling code should dispose the Speaker after this event is
+ /// triggered.
+ ///
+ public event OnErrorDelegate? Error;
+
+ ///
+ /// Event that is triggered when a message is received.
+ ///
+ public event OnReceiveDelegate? Receive;
+
+ ///
+ /// Instantiates a speaker. The speaker will not perform any I/O until StartAsync is called.
+ ///
+ /// Stream to use for I/O
+ public Speaker(Stream conn)
+ {
+ _conn = conn;
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ Error = null;
+ await _cts.CancelAsync();
+ if (_receiveTask is not null) await _receiveTask.WaitAsync(TimeSpan.FromSeconds(5));
+ await _conn.DisposeAsync();
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// Performs a handshake with the peer and starts the async receive loop. The caller should attach it's Receive and
+ /// Error event handlers before calling this method.
+ ///
+ public async Task StartAsync(CancellationToken ct = default)
+ {
+ // Handshakes should always finish quickly, so enforce a 5s timeout.
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token);
+ cts.CancelAfter(TimeSpan.FromSeconds(5));
+ await PerformHandshake(ct);
+
+ // Start ReceiveLoop in the background.
+ _receiveTask = ReceiveLoop(_cts.Token);
+ _ = _receiveTask.ContinueWith(t =>
+ {
+ if (t.IsFaulted) Error?.Invoke(t.Exception!);
+ }, CancellationToken.None);
+ }
+
+ private async Task PerformHandshake(CancellationToken ct = default)
+ {
+ // Simultaneously write the header string and read the header string in
+ // case the conn is not buffered.
+ var headerCts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token);
+ var writeTask = WriteHeader(headerCts.Token);
+ var readTask = ReadHeader(headerCts.Token);
+ await TaskUtilities.CancellableWhenAll(headerCts, writeTask, readTask);
+
+ var header = RpcHeader.Parse(await readTask);
+ var expectedRole = RpcMessage.GetRole();
+ if (header.Role != expectedRole)
+ throw new ArgumentException($"Expected peer role '{expectedRole}' but got '{header.Role}'");
+
+ header.Version.Validate(ApiVersion.Current);
+ }
+
+ private async Task WriteHeader(CancellationToken ct = default)
+ {
+ var header = new RpcHeader(RpcMessage.GetRole(), ApiVersion.Current);
+ await _conn.WriteAsync(header.ToBytes(), ct);
+ }
+
+ private async Task ReadHeader(CancellationToken ct = default)
+ {
+ var buf = new byte[256];
+ var have = 0;
+
+ while (true)
+ {
+ // Read into buf[have:have+1]
+ await _conn.ReadExactlyAsync(buf, have, 1, ct);
+ if (buf[have] == '\n') break;
+ have++;
+ if (have >= buf.Length)
+ throw new IOException($"Header malformed or too large: '{Encoding.UTF8.GetString(buf)}'");
+ }
+
+ return Encoding.UTF8.GetString(buf, 0, have);
+ }
+
+ private async Task ReceiveLoop(CancellationToken ct = default)
+ {
+ try
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ var message = await _serdes.ReadMessage(_conn, ct);
+ if (message is { RpcField.ResponseTo : not 0 })
+ {
+ // Look up the TaskCompletionSource for the message ID and
+ // complete it with the message.
+ if (_pendingReplies.TryRemove(message.RpcField.ResponseTo, out var tcs))
+ tcs.SetResult(message);
+ // TODO: we should log unknown replies
+ continue;
+ }
+
+ // Start a new task in the background to handle the message.
+ _ = Task.Run(() => Receive?.Invoke(new ReplyableRpcMessage(this, message)), ct);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Ignore, this is expected when being disposed.
+ }
+ catch (Exception e)
+ {
+ if (!ct.IsCancellationRequested) Error?.Invoke(e);
+ }
+ }
+
+ ///
+ /// Send a message that does not expect a reply.
+ ///
+ /// Message to send
+ /// Optional cancellation token
+ public async Task SendMessage(TS message, CancellationToken ct = default)
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token);
+ message.RpcField = null;
+ await _serdes.WriteMessage(_conn, message, cts.Token);
+ }
+
+ ///
+ /// Send a message and wait for a reply. The reply will be returned and the callback will not be invoked as long as the
+ /// reply is received before cancellation.
+ ///
+ /// Message to send - the Rpc field will be overwritten
+ /// Optional cancellation token
+ /// Received reply
+ public async ValueTask SendRequestAwaitReply(TS message, CancellationToken ct = default)
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token);
+ message.RpcField = new RPC
+ {
+ MsgId = Interlocked.Add(ref _lastRequestId, 1),
+ ResponseTo = 0,
+ };
+
+ // Configure a TaskCompletionSource to complete when the reply is
+ // received.
+ var tcs = new TaskCompletionSource ();
+ _pendingReplies[message.RpcField.MsgId] = tcs;
+ try
+ {
+ await _serdes.WriteMessage(_conn, message, cts.Token);
+ // Wait for the reply to be received.
+ return await tcs.Task.WaitAsync(cts.Token);
+ }
+ finally
+ {
+ // Clean up the pending reply if it was not received before
+ // cancellation or another exception occurred.
+ _pendingReplies.TryRemove(message.RpcField.MsgId, out _);
+ }
+ }
+
+ ///
+ /// Sends a reply to a received message.
+ ///
+ /// Message to reply to - the Rpc field will be overwritten
+ /// Reply message
+ /// Optional cancellation token
+ /// The original message is not a request and cannot be replied to
+ public async Task SendReply(TR originalMessage, TS reply, CancellationToken ct = default)
+ {
+ if (originalMessage.RpcField == null || originalMessage.RpcField.MsgId == 0)
+ throw new ArgumentException("Original message is not a request");
+
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token);
+ reply.RpcField = new RPC
+ {
+ MsgId = 0,
+ ResponseTo = originalMessage.RpcField.MsgId,
+ };
+ await _serdes.WriteMessage(_conn, reply, cts.Token);
+ }
+}
diff --git a/Vpn/Utilities/TaskUtilities.cs b/Vpn/Utilities/TaskUtilities.cs
new file mode 100644
index 0000000..8a2bfdb
--- /dev/null
+++ b/Vpn/Utilities/TaskUtilities.cs
@@ -0,0 +1,59 @@
+namespace Coder.Desktop.Vpn.Utilities;
+
+internal static class TaskUtilities
+{
+ ///
+ /// Waits for all tasks to complete, but cancels the provided CancellationTokenSource if any task is canceled or
+ /// faulted. The first cancel or fault will be propagated to the returned Task. All passed in tasks must be using the
+ /// same CancellationTokenSource .
+ /// The returned task will wait for all tasks to be completed.
+ ///
+ ///
+ ///
+ /// var cts = new CancellationTokenSource();
+ /// var task1 = Task.Delay(1000, cts.Token);
+ /// var task2 = Task.Delay(2000, cts.Token);
+ /// await TaskUtilities.CancellableWhenAll(cts, task1, task2);
+ ///
+ ///
+ /// Tasks to wait on
+ /// The cancellation token source that was provided to each task
+ ///
+ /// A task that completes when all tasks are completed, with the cancellation or exception state of the first
+ /// non-successful task
+ ///
+ public static async Task CancellableWhenAll(CancellationTokenSource cts, params Task[] tasks)
+ {
+ var taskList = tasks.ToList();
+ if (taskList.Count == 0) return;
+ var tcs = new TaskCompletionSource();
+
+ var tasksWithCancellation = taskList.Select(task =>
+ task.ContinueWith(t =>
+ {
+ if (t.IsFaulted)
+ {
+ cts.Cancel();
+ tcs.TrySetException(t.Exception.InnerExceptions.First());
+ }
+ else if (t.IsCanceled)
+ {
+ cts.Cancel();
+ tcs.TrySetCanceled();
+ }
+ }));
+
+ // Wait for all the task continuations to complete.
+ try
+ {
+ await Task.WhenAll(tasksWithCancellation);
+ tcs.TrySetResult();
+ }
+ catch
+ {
+ // Exception was already propagated.
+ }
+
+ await tcs.Task;
+ }
+}
diff --git a/Vpn/Vpn.csproj b/Vpn/Vpn.csproj
new file mode 100644
index 0000000..bcef1b5
--- /dev/null
+++ b/Vpn/Vpn.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Coder.Desktop.Vpn
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+