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 + + + + + + +