pax_global_header00006660000000000000000000000064145075715750014532gustar00rootroot0000000000000052 comment=97233d20448e1c3cb0e0fd9114acf68c7e5c0249 dev-tunnels-0.0.25/000077500000000000000000000000001450757157500140625ustar00rootroot00000000000000dev-tunnels-0.0.25/.github/000077500000000000000000000000001450757157500154225ustar00rootroot00000000000000dev-tunnels-0.0.25/.github/pull_request_template.md000066400000000000000000000002101450757157500223540ustar00rootroot00000000000000Fixes # ### Changes proposed: - - - ### Other Tasks: - [ ] If you updated the Go SDK did you update the PackageVersion in tunnels.go dev-tunnels-0.0.25/.github/workflows/000077500000000000000000000000001450757157500174575ustar00rootroot00000000000000dev-tunnels-0.0.25/.github/workflows/check-basis.yml000066400000000000000000000004561450757157500223630ustar00rootroot00000000000000name: Check Basis on: pull_request: jobs: no-basis: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Check for Basis run: | if grep -iR basis --exclude-dir=.github --exclude-dir=.git .; then exit 1 else exit 0 fi dev-tunnels-0.0.25/.github/workflows/codeql-analysis.yml000066400000000000000000000044321450757157500232750ustar00rootroot00000000000000# For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # # ******** NOTE ******** # We have attempted to detect the languages in your repository. Please check # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # name: "CodeQL" on: push: branches: [ main ] pull_request: # The branches below must be a subset of the branches above branches: [ main ] schedule: - cron: '45 21 * * 1' jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'go' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://git.io/codeql-language-support steps: - name: Checkout repository uses: actions/checkout@v2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v1 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v1 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines # and modify them (or add more) to build your code if your project # uses a compiled language #- run: | # make bootstrap # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v1 dev-tunnels-0.0.25/.github/workflows/go.yml000066400000000000000000000031261450757157500206110ustar00rootroot00000000000000name: GoBuildAndTest on: push: branches: [ main ] pull_request: branches: [ main ] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Go uses: actions/setup-go@v2 with: go-version: 1.17 - name: Build run: cd go/tunnels && go build -v ./... test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Go uses: actions/setup-go@v2 with: go-version: 1.17 - name: Test run: cd go/tunnels && go test -short -v ./... check-version-update: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 with: fetch-depth: 0 - name: Check Go SDK package version updated run: | SDK_DIR=go FILE_WITH_PKG_VER=go/tunnels/tunnels.go test -d $SDK_DIR && test -f $FILE_WITH_PKG_VER BASE_SHA=$(jq -r '.pull_request.base.sha' "$GITHUB_EVENT_PATH") # If go sdk dir updated, package version file updated and "PackageVersion" line updated, success; else error. if git diff --name-only $BASE_SHA HEAD | grep -q "^$SDK_DIR"; then if git diff --name-only $BASE_SHA HEAD | grep -q "^$FILE_WITH_PKG_VER" && git diff $BASE_SHA HEAD -- $FILE_WITH_PKG_VER | grep -q "PackageVersion"; then echo "Success: Package version was updated." exit 0 else echo "Error: An error occurred. Has "PackageVersion" in $FILE_WITH_PKG_VER been updated?" >&2 exit 1 fi else echo "No Go SDK changes detected." fi dev-tunnels-0.0.25/.github/workflows/java-sdk-release.yml000066400000000000000000000013411450757157500233170ustar00rootroot00000000000000name: Publish package to GitHub Packages on: release: types: [created] defaults: run: working-directory: java jobs: publish: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v2 - uses: actions/setup-java@v2 with: java-version: "11" distribution: "adopt" - name: Set java release version env variable run: echo "JAVA_RELEASE_VERSION=$(echo ${{github.ref_name}} | cut -c 7-)" >> $GITHUB_ENV - name: Publish package run: mvn --batch-mode deploy --file pom.xml -Drevision=$JAVA_RELEASE_VERSION -Dmaven.test.skip=true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} dev-tunnels-0.0.25/.github/workflows/maven.yml000066400000000000000000000014371450757157500213150ustar00rootroot00000000000000# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time # For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven name: Java CI with Maven on: push: branches: [main] pull_request: branches: [main] defaults: run: working-directory: java jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up JDK 11 uses: actions/setup-java@v2 with: java-version: "11" distribution: "microsoft" cache: maven - name: Build with Maven # don't run ConnectionTest for now. run: mvn -B package --file pom.xml -Dtest=TunnelContractsTests dev-tunnels-0.0.25/.github/workflows/rust.yml000066400000000000000000000005741450757157500212050ustar00rootroot00000000000000name: Rust on: push: branches: [ "main" ] pull_request: branches: [ "main" ] env: CARGO_TERM_COLOR: always jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Build run: cargo build --verbose --manifest-path=rs/Cargo.toml - name: Run tests run: cargo test --verbose --manifest-path=rs/Cargo.toml dev-tunnels-0.0.25/.gitignore000066400000000000000000000135621450757157500160610ustar00rootroot00000000000000## 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/master/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/ [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/ # StyleCop StyleCopReport.xml # Files built by Visual Studio *_i.c *_p.c *_h.h *.ilk *.meta *.obj *.iobj *.pch *.pdb *.ipdb *.pgc *.pgd *.rsp *.sbr *.tlb *.tli *.tlh *.tmp *.tmp_proj *_wpftmp.csproj *.log *.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 # 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 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/ # 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/ dev-tunnels-0.0.25/.pipelines/000077500000000000000000000000001450757157500161305ustar00rootroot00000000000000dev-tunnels-0.0.25/.pipelines/cs-build-steps.yaml000066400000000000000000000024601450757157500216540ustar00rootroot00000000000000parameters: - name: Pdb2PdbExe type: string default: '' steps: - task: UseDotNet@2 displayName: "Install DotNet SDK" inputs: packageType: 'sdk' workingDirectory: 'cs' performMultiLevelLookup: true useGlobalJson: true - task: NuGetAuthenticate@1 displayName: 'NuGet Authenticate' - task: DotNetCoreCLI@2 displayName: "DotNet Restore" inputs: command: 'restore' workingDirectory: 'cs' projects: 'cs/TunnelsSDK.sln' nugetConfigPath: 'cs/NuGet.config' verbosityRestore: 'Minimal' feedsToUse: 'config' - task: DotNetCoreCLI@2 displayName: "DotNet Build" inputs: command: 'build' workingDirectory: 'cs' projects: 'cs/TunnelsSDK.sln' arguments: '-v:n --no-restore -c Release -p:EnableSigning="$(enableSigning)" -p:SignType="$(signType)" -p:Pdb2PdbExe="${{ parameters.Pdb2PdbExe }}"' - task: DotNetCoreCLI@2 displayName: "DotNet Test" inputs: command: 'test' workingDirectory: 'cs' projects: 'cs/TunnelsSDK.sln' publishTestResults: true arguments: '-v:n -c release -p:CodeCoverage=true --no-build' - task: PublishCodeCoverageResults@1 displayName: 'Publish code coverage' inputs: codeCoverageTool: Cobertura summaryFileLocation: cs/bin/release/testresults/coverage/TunnelsSDK/Cobertura.xml failIfCoverageEmpty: true dev-tunnels-0.0.25/.pipelines/cs-ci-build.yaml000066400000000000000000000055501450757157500211140ustar00rootroot00000000000000pool: name: VSEngSS-MicroBuild2022-1ES # Trigger only if changes to cs directory are committed to main. trigger: branches: include: - main paths: include: - cs pr: none variables: TeamName: "Visual Studio" enableSigning: true signType: real Codeql.Enabled: true Pdb2PdbVersion: '1.1.0-beta2-23052-02' steps: - task: ms-vseng.MicroBuildTasks.30666190-6959-11e5-9f96-f56098202fef.MicroBuildSigningPlugin@3 displayName: 'Install Signing Plugin' inputs: signType: $(signType) - task: NuGetToolInstaller@1 inputs: versionSpec: '6.4.x' # To archive our debug symbols with symweb, we need to convert the portable .pdb files that we build to windows .pdb files first # https://devdiv.visualstudio.com/DevDiv/_wiki/wikis/DevDiv.wiki/672/Archive-Symbols-with-Symweb?anchor=portable-pdbs - task: NuGetCommand@2 displayName: Install Pdb2Pdb for Symbol Archiving inputs: command: custom arguments: 'install Microsoft.DiaSymReader.Pdb2Pdb -version $(Pdb2PdbVersion) -PackageSaveMode nuspec -OutputDirectory $(Agent.TempDirectory) -Source https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-tools/nuget/v3/index.json' - template: cs-build-steps.yaml parameters: Pdb2PdbExe: '$(Agent.TempDirectory)\Microsoft.DiaSymReader.Pdb2Pdb.$(Pdb2PdbVersion)\tools\Pdb2Pdb.exe' - task: DotNetCoreCLI@2 displayName: "DotNet Pack" inputs: command: 'pack' nobuild: true workingDirectory: 'cs' projects: 'cs/dirs.proj' verbosityPack: 'Normal' packagesToPack: 'cs/**/*.csproj' configuration: 'Release' packDirectory: '$(Build.ArtifactStagingDirectory)' - task: PublishBuildArtifacts@1 displayName: Publish symbols to drop artifacts inputs: pathtoPublish: '$(System.DefaultWorkingDirectory)\cs\bin\release\sym' artifactName: symbols publishLocation: 'Container' # docs: https://eng.ms/docs/cloud-ai-platform/devdiv/one-engineering-system-1es/1es-docs/azure-artifacts/symbol-service - task: PublishSymbols@2 displayName: Publish symbols to Microsoft Server (https://symweb) inputs: SymbolsFolder: '$(System.DefaultWorkingDirectory)\cs\bin\release\sym' SearchPattern: '**\*.pdb' SymbolServerType: TeamServices # Expiration parameter: https://eng.ms/docs/cloud-ai-platform/devdiv/one-engineering-system-1es/1es-docs/azure-artifacts/symbol-service#how-to-change-the-expiration-date-of-a-symbol-request SymbolExpirationInDays: '1095' - task: PublishBuildArtifacts@1 displayName: Publish Build inputs: pathtoPublish: '$(Build.ArtifactStagingDirectory)' artifactName: 'drop' publishLocation: 'Container' - task: NuGetCommand@2 displayName: "Publish NuGet packages to nuget.org" inputs: command: 'push' nugetFeedType: 'external' packagesToPush: '$(Build.ArtifactStagingDirectory)/*.nupkg' publishFeedCredentials: 'dev-tunnels-nuget' publishPackageMetadata: truedev-tunnels-0.0.25/.pipelines/cs-pr-build.yaml000066400000000000000000000005031450757157500211330ustar00rootroot00000000000000pool: name: Azure Pipelines # Do not trigger on non-PR pushes. trigger: none # Trigger on PRs to `main` branch when there are changes in the `cs` folder. pr: branches: include: - main paths: include: - cs autoCancel: true variables: enableSigning: false steps: - template: cs-build-steps.yaml dev-tunnels-0.0.25/.pipelines/typescript-build-and-publish.yaml000066400000000000000000000010131450757157500245160ustar00rootroot00000000000000pool: name: Azure Pipelines # Publish only if changes to TypeScript directory are committed to main. trigger: branches: include: - main paths: include: - ts pr: none variables: Codeql.Enabled: true steps: - template: typescript-build-steps.yaml - task: Npm@1 displayName: 'Publish packages to external feed' inputs: command: custom workingDir: ts verbose: false customCommand: 'run publish --access public' customRegistry: useNpmrc customEndpoint: 'dev-tunnels-npm' dev-tunnels-0.0.25/.pipelines/typescript-build-steps.yaml000066400000000000000000000014031450757157500234510ustar00rootroot00000000000000steps: - task: NodeTool@0 displayName: 'Use Node 18.x' inputs: versionSpec: 18.x - task: Npm@1 displayName: 'Restore npm packages' inputs: workingDir: ts - task: Npm@1 displayName: Compile inputs: command: custom workingDir: ts customCommand: 'run compile' - task: Npm@1 displayName: Lint inputs: command: custom workingDir: ts customCommand: 'run eslint' - task: Npm@1 displayName: Build inputs: command: custom workingDir: ts customCommand: 'run build' - task: Npm@1 displayName: Run unit tests inputs: command: custom workingDir: ts customCommand: 'run test' - task: Npm@1 displayName: Pack inputs: command: custom workingDir: ts customCommand: 'run pack --release' dev-tunnels-0.0.25/.pipelines/typescript-pr-build.yaml000066400000000000000000000005751450757157500227450ustar00rootroot00000000000000pool: name: Azure Pipelines # Do not trigger on non-PR pushes. trigger: none # Trigger on PRs to `main` branch when there are changes in the `ts` folder. pr: branches: include: - main paths: include: - ts autoCancel: true steps: - template: typescript-build-steps.yaml - task: ComponentGovernanceComponentDetection@0 displayName: Component Governance dev-tunnels-0.0.25/.vscode/000077500000000000000000000000001450757157500154235ustar00rootroot00000000000000dev-tunnels-0.0.25/.vscode/settings.json000066400000000000000000000002041450757157500201520ustar00rootroot00000000000000{ "eslint.workingDirectories": [ "ts" ], "rust-analyzer.cargo.features": ["connections", "vendored-openssl"], } dev-tunnels-0.0.25/CODE_OF_CONDUCT.md000066400000000000000000000006741450757157500166700ustar00rootroot00000000000000# Microsoft Open Source Code of Conduct This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). Resources: - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns dev-tunnels-0.0.25/CONTRIBUTING.md000066400000000000000000000016451450757157500163210ustar00rootroot00000000000000# Contributing This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. When you submit a pull request, a CLA bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA. This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. dev-tunnels-0.0.25/LICENSE000066400000000000000000000021651450757157500150730ustar00rootroot00000000000000 MIT License Copyright (c) Microsoft Corporation. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE dev-tunnels-0.0.25/README.md000066400000000000000000000116651450757157500153520ustar00rootroot00000000000000[![NuGet version](https://badge.fury.io/nu/Microsoft.DevTunnels.Connections.svg)](https://badge.fury.io/nu/Microsoft.DevTunnels.Connections) [![npm version](https://badge.fury.io/js/%40microsoft%2Fdev-tunnels-connections.svg)](https://badge.fury.io/js/%40microsoft%2Fdev-tunnels-connections) # Dev tunnels Dev tunnels allows developers to securely expose local web services to the Internet, control who has access, and easily & debug your web applications from anywhere. Learn more at [Dev tunnels documentation](https://aka.ms/devtunnels/docs). ## SDK Feature Matrix | Feature | C# | TypeScript | Java | Go | Rust | |---|---|---|---|---|---| | Management API | ✅ | ✅ | ✅ | ✅ | ✅ | | Tunnel Client Connections | ✅ | ✅ | ✅ | ✅ | ✅ | | Tunnel Host Connections | ✅ | ✅ | ❌ | ❌ | ✅ | | Reconnection | ✅ | ✅ | 🗓️ | 🗓️ | ❌ | | SSH-level Reconnection | ✅ | ✅ | ❌ | ❌ | ❌ | | Automatic tunnel access token refresh | ✅ | ✅ | 🗓️ | 🗓️ | ❌ | ✅ - Supported 🚧 - In Progress ❌ - Not Supported 🗓️ - Planned ## Resources ### Documentation - [Dev tunnels documentation](https://aka.ms/devtunnels/docs) - [Announcing the public preview of the devtunnel CLI](http://aka.ms/devtunnels/blog/cli) - [Dev tunnels in Visual Studio 2022](http://aka.ms/devtunnels/vs) - [Where else is dev tunnels used?](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/faq#where-else-is-dev-tunnels-used) ### Videos #### Official - [Securely test and debug your web apps and webhooks with dev tunnels](https://www.youtube.com/watch?v=yBiOGgUFD68) - [Advanced developer tips and tricks in Visual Studio](https://youtu.be/Czr2M9qcdW4?t=491) #### Community-created - [ASP.NET Community Standup - Dev tunnels in Visual Studio for ASP.NET Core projects](https://youtu.be/B9K9eseNcKE?t=185) - [New Visual Studio Feature is a Game Changer for API Developers - Put localhost Online](https://www.youtube.com/watch?v=NPJhrftkqeg) - [Connect Any Client, Anywhere to localhost with Visual Studio Dev Tunnels!](https://www.youtube.com/watch?v=azuC8SFHWp8) - [Dev Tunnels Visual Studio in 10 Minutes or Less](https://www.youtube.com/watch?v=kdaHwOkQf7c) - [Share Local Web Services Across the Internet with Dev Tunnels CLI](https://www.youtube.com/watch?v=doUDcQNoy38) - [ChatGPT Plugin development with Visual Studio](https://www.youtube.com/watch?v=iB9oxyJZhSA) ## Feedback Have a question or feedback? There are many ways to submit feedback. - [Up-vote a feature or request a new one](https://github.com/microsoft/dev-tunnels/issues?q=is%3Aissue+is%3Aopen+label%3Afeature-request) - Search [existing bugs](https://github.com/microsoft/dev-tunnels/issues?q=is%3Aissue+is%3Aopen+label%3Abug) or [file a new issue](https://github.com/microsoft/dev-tunnels/issues/new) ## Contributing This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. When you submit a pull request, a CLA bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA. This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. ## Security Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)), please report it to us as described in [Security](SECURITY.md). ## Trademarks This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft trademarks or logos is subject to and must follow [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party's policies. dev-tunnels-0.0.25/SECURITY.md000066400000000000000000000053341450757157500156600ustar00rootroot00000000000000 ## Security Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)), please report it to us as described below. ## Reporting Security Issues **Please do not report security vulnerabilities through public GitHub issues.** Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report). If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/en-us/msrc/pgp-key-msrc). You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) * Full paths of source file(s) related to the manifestation of the issue * The location of the affected source code (tag/branch/commit or direct URL) * Any special configuration required to reproduce the issue * Step-by-step instructions to reproduce the issue * Proof-of-concept or exploit code (if possible) * Impact of the issue, including how an attacker might exploit the issue This information will help us triage your report more quickly. If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs. ## Preferred Languages We prefer all communications to be in English. ## Policy Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd). dev-tunnels-0.0.25/SUPPORT.md000066400000000000000000000007261450757157500155650ustar00rootroot00000000000000# Support ## How to file issues and get help This project uses GitHub Issues to track bugs and feature requests. Please search the existing issues before filing new issues to avoid duplicates. For new issues, file your bug or feature request as a new Issue. For help and questions about using this project, please email tunnelsfeedback@microsoft.com ## Microsoft Support Policy Support for this project is limited to the resources listed above. dev-tunnels-0.0.25/cs/000077500000000000000000000000001450757157500144675ustar00rootroot00000000000000dev-tunnels-0.0.25/cs/.editorconfig000066400000000000000000000012051450757157500171420ustar00rootroot00000000000000; EditorConfig to support per-solution formatting. ; Use the EditorConfig VS add-in to make this work. ; http://editorconfig.org/ ; This is the default for the codeline. root = true [*] indent_style = space charset = utf-8 trim_trailing_whitespace = true ; Code files [*.{cs}] indent_size = 4 ; Default to file-scoped namespaces for new C# files, but don't warn about exsting block scope. (Change to warning after all code is updated.) csharp_style_namespace_declarations = file_scoped:none ; All XML-based file formats [*.{config,csproj,nuspec,props,resx,ruleset,targets,vsct,vsixmanifest,xaml,xml,vsmanproj,swixproj,proj}] indent_size = 2 dev-tunnels-0.0.25/cs/.gitignore000066400000000000000000000000351450757157500164550ustar00rootroot00000000000000bin .vs .store nbgv.exe docs dev-tunnels-0.0.25/cs/Directory.Build.props000066400000000000000000000021551450757157500205610ustar00rootroot00000000000000 true true Microsoft.VsSaaS.Services 10.0 enable $(MSBuildThisFileDirectory) Microsoft © Microsoft Corporation. All rights reserved. Microsoft Git MIT https://github.com/microsoft/dev-tunnels https://github.com/microsoft/dev-tunnels DevTunnels Tunnel Service Microsoft;VSTunnels;DevTunnels DevTunnels Tunnel Service dev-tunnels-0.0.25/cs/Directory.Build.targets000066400000000000000000000001241450757157500210610ustar00rootroot00000000000000 dev-tunnels-0.0.25/cs/NuGet.config000066400000000000000000000007141450757157500167020ustar00rootroot00000000000000 dev-tunnels-0.0.25/cs/TunnelsSDK.sln000066400000000000000000000061251450757157500172030ustar00rootroot00000000000000ďťż Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.1.32127.271 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevTunnels.Management", "src\Management\DevTunnels.Management.csproj", "{6D69CF22-EFEB-4D00-A2BB-D9F99575F4C7}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevTunnels.Contracts", "src\Contracts\DevTunnels.Contracts.csproj", "{0D0A9DE3-52B5-4DD5-8C6F-CD968051E56A}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevTunnels.Connections", "src\Connections\DevTunnels.Connections.csproj", "{A4231A49-C21D-4134-92EA-C90D575B6A18}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TunnelsSDK.Test", "test\TunnelsSDK.Test\TunnelsSDK.Test.csproj", "{4C14DFCD-76D6-43B4-8FE4-3D132E0CD15A}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TunnelsSDK.Generator", "tools\TunnelsSDK.Generator\TunnelsSDK.Generator.csproj", "{30509913-487D-4058-BB3A-56683BA0AE6F}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {6D69CF22-EFEB-4D00-A2BB-D9F99575F4C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6D69CF22-EFEB-4D00-A2BB-D9F99575F4C7}.Debug|Any CPU.Build.0 = Debug|Any CPU {6D69CF22-EFEB-4D00-A2BB-D9F99575F4C7}.Release|Any CPU.ActiveCfg = Release|Any CPU {6D69CF22-EFEB-4D00-A2BB-D9F99575F4C7}.Release|Any CPU.Build.0 = Release|Any CPU {0D0A9DE3-52B5-4DD5-8C6F-CD968051E56A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0D0A9DE3-52B5-4DD5-8C6F-CD968051E56A}.Debug|Any CPU.Build.0 = Debug|Any CPU {0D0A9DE3-52B5-4DD5-8C6F-CD968051E56A}.Release|Any CPU.ActiveCfg = Release|Any CPU {0D0A9DE3-52B5-4DD5-8C6F-CD968051E56A}.Release|Any CPU.Build.0 = Release|Any CPU {A4231A49-C21D-4134-92EA-C90D575B6A18}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A4231A49-C21D-4134-92EA-C90D575B6A18}.Debug|Any CPU.Build.0 = Debug|Any CPU {A4231A49-C21D-4134-92EA-C90D575B6A18}.Release|Any CPU.ActiveCfg = Release|Any CPU {A4231A49-C21D-4134-92EA-C90D575B6A18}.Release|Any CPU.Build.0 = Release|Any CPU {4C14DFCD-76D6-43B4-8FE4-3D132E0CD15A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4C14DFCD-76D6-43B4-8FE4-3D132E0CD15A}.Debug|Any CPU.Build.0 = Debug|Any CPU {4C14DFCD-76D6-43B4-8FE4-3D132E0CD15A}.Release|Any CPU.ActiveCfg = Release|Any CPU {4C14DFCD-76D6-43B4-8FE4-3D132E0CD15A}.Release|Any CPU.Build.0 = Release|Any CPU {30509913-487D-4058-BB3A-56683BA0AE6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {30509913-487D-4058-BB3A-56683BA0AE6F}.Debug|Any CPU.Build.0 = Debug|Any CPU {30509913-487D-4058-BB3A-56683BA0AE6F}.Release|Any CPU.ActiveCfg = Release|Any CPU {30509913-487D-4058-BB3A-56683BA0AE6F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3866D33E-026A-42D8-9B48-B13797175870} EndGlobalSection EndGlobal dev-tunnels-0.0.25/cs/build/000077500000000000000000000000001450757157500155665ustar00rootroot00000000000000dev-tunnels-0.0.25/cs/build/PublicKey.snk000066400000000000000000000002401450757157500201660ustar00rootroot00000000000000$€”$RSA1ŃúWÄŽŮđŁ.„ŞŽý éčýj쏇űvlƒL™’˛;çšŮŐÜÁݚŇ6! r<ů€•ÄáwĆwO)č2’ęěäč!ŔĽďčńd\L “ÁŤ™(]b,Şe,úÖ=t]o-ĺń~^ŻĖ=&ŠCe mŔ“4MZғdev-tunnels-0.0.25/cs/build/build.props000066400000000000000000000223411450757157500177540ustar00rootroot00000000000000 $([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), '.gitignore'))\ $(GitRoot)src\ $(GitRoot)build\ Debug $(GitRoot)bin\$(Configuration.ToLowerInvariant())\ $(BaseOutputPath)$(MSBuildProjectName)\ $(BaseOutputPath)nupkgs\ $(BaseOutputPath)sym\ false $(GitRoot)bin\obj\$(MSBuildProjectName)\ true Microsoft.VsCloudKernel.Services $(RootName).$(MSBuildProjectName) $(RootName).$(MSBuildProjectName) false false $(NoWarn);NU1608 true true netcoreapp3.1 netstandard2.1 netstandard2.1 7.6.812 5.4.1 3.3.0 0.3.0 1.0.3 15.8.0 4.9.0 3.4.255 4.8.13 4.7.2 15.5.31 3.11.31 2.4.0 2.4.0 PreserveNewest false false true true false false false false $(MSBuildProjectName.Replace('.Test', '')) $(BaseOutputPath)testresults $(TestResultsDirectory) trx%3BLogFileName=$(TestBaseName).trx $(NoWarn);VSTHRD200 true $(TestResultsDirectory)/$(TestBaseName)-coverage.xml Interop|Test|xunit|AltCover|System.Reactive ThisAssembly|System.Runtime|CodeAnalysis $(TestResultsDirectory)/$(TestBaseName)-lcov.info false all runtime; build; native; contentfiles; analyzers; buildtransitive $(MSBuildThisFileDirectory)CodeAnalysis.ruleset true all runtime; build; native; contentfiles; analyzers; buildtransitive true 002400000480000094000000060200000024000052534131000400000100010007d1fa57c4aed9f0a32e84aa0faefd0de9e8fd6aec8f87fb03766c834c99921eb23be79ad9d5dcc1dd9ad236132102900b723cf980957fc4e177108fc607774f29e8320e92ea05ece4e821c0a5efe8f1645c4c0c93c1ab99285d622caa652c1dfad63d745d6f2de5f17e5eaf0fc4963d261c8a12436518206dc093344d5ad293 b03f5f7f11d50a3a $(MSBuildThisFileDirectory)PublicKey.snk true true Microsoft400 StrongName $(NoWarn);NU5105 dev-tunnels-0.0.25/cs/build/build.targets000066400000000000000000000001751450757157500202630ustar00rootroot00000000000000 dev-tunnels-0.0.25/cs/build/test.targets000066400000000000000000000036721450757157500201500ustar00rootroot00000000000000 $(TestResultsDirectory)\coverage\$(TestBaseName) $(CoverageDir)\Summary.txt $(CoverageDir)\index.htm dev-tunnels-0.0.25/cs/global.json000066400000000000000000000000531450757157500166200ustar00rootroot00000000000000{ "sdk": { "version": "6.0.412" } }dev-tunnels-0.0.25/cs/src/000077500000000000000000000000001450757157500152565ustar00rootroot00000000000000dev-tunnels-0.0.25/cs/src/Connections/000077500000000000000000000000001450757157500175405ustar00rootroot00000000000000dev-tunnels-0.0.25/cs/src/Connections/ConnectionStatus.cs000066400000000000000000000022061450757157500233720ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // namespace Microsoft.DevTunnels.Connections; /// /// Tunnel client or host connection status. /// public enum ConnectionStatus { /// /// The connection has not started yet. This is the initial status. /// None, /// /// Connecting (if changed from None) or reconnecting (if changed from Connected) to the service. /// Connecting, /// /// Connecting and refreshing the tunnel access token to connect with. /// RefreshingTunnelAccessToken, /// /// Connected to the service. /// Connected, /// /// Disconnected from the service and could not reconnect either due to disposal, service down, tunnel deleted, or token expiration. This is the final status. /// Disconnected, /// /// Refreshing tunnel host public key. /// RefreshingTunnelHostPublicKey, } dev-tunnels-0.0.25/cs/src/Connections/ConnectionStatusChangedEventArgs.cs000066400000000000000000000023751450757157500264720ustar00rootroot00000000000000ďťż// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Collections.Generic; using System.Text; namespace Microsoft.DevTunnels.Connections; /// /// Connection status change event args. /// public class ConnectionStatusChangedEventArgs : EventArgs { /// /// Create a new instance of . /// public ConnectionStatusChangedEventArgs(ConnectionStatus previousStatus, ConnectionStatus status, Exception? disconnectException) { PreviousStatus = previousStatus; Status = status; DisconnectException = disconnectException; } /// /// Get the previous connection status. /// public ConnectionStatus PreviousStatus { get; } /// /// Get the current connection status. /// public ConnectionStatus Status { get; } /// /// Get the exception that caused disconnect if is . /// public Exception? DisconnectException { get; } } dev-tunnels-0.0.25/cs/src/Connections/DevTunnels.Connections.csproj000066400000000000000000000030261450757157500253330ustar00rootroot00000000000000 Microsoft.DevTunnels.Connections Microsoft.DevTunnels.Connections netcoreapp3.1;net6.0 true true false True CS1591 NU1701 <_FakeOutputPath Include="$(MSBuildProjectDirectory)\$(PackageOutputPath)\$(AssemblyName).UNK" /> dev-tunnels-0.0.25/cs/src/Connections/IRelayClient.cs000066400000000000000000000034361450757157500224210ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Diagnostics; using System.IO; using System.Threading; using System.Threading.Tasks; using Microsoft.DevTunnels.Ssh; namespace Microsoft.DevTunnels.Connections; /// /// Relay service client. /// internal interface IRelayClient { /// /// Get tunnel access scope for this tunnel client or host. /// string TunnelAccessScope { get; } /// /// Gets the trace source. /// TraceSource Trace { get; } /// /// Create stream to the tunnel. /// Task CreateSessionStreamAsync(CancellationToken cancellation); /// /// Gets the connection protocol (websocket subprotocol) that was negotiated between client and server. /// string? ConnectionProtocol { get; } /// /// Configures tunnel SSH session with the given stream. /// Task ConfigureSessionAsync(Stream stream, bool isReconnect, CancellationToken cancellation); /// /// Closes tunnel SSH session due to an error or exception. /// Task CloseSessionAsync(SshDisconnectReason disconnectReason, Exception? exception); /// /// Refresh tunnel access token. This may be useful when the Relay service responds with 401 Unauthorized. /// Task RefreshTunnelAccessTokenAsync(CancellationToken cancellation); /// /// Notifies about a connection retry, giving the relay client a chance to delay or cancel it. /// void OnRetrying(RetryingTunnelConnectionEventArgs e); } dev-tunnels-0.0.25/cs/src/Connections/ITunnelClient.cs000066400000000000000000000204751450757157500226140ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Collections.Generic; using System.IO; using System.Net; using System.Threading; using System.Threading.Tasks; using Microsoft.DevTunnels.Ssh.Tcp.Events; using Microsoft.DevTunnels.Contracts; using Microsoft.DevTunnels.Management; namespace Microsoft.DevTunnels.Connections; /// /// Interface for a client capable of making a connection to a tunnel and /// forwarding ports over the tunnel. /// public interface ITunnelClient : IAsyncDisposable { /// /// Gets the list of connection modes that this client supports. /// IReadOnlyCollection ConnectionModes { get; } /// /// Gets list of ports forwarded to client, this collection /// contains events to notify when ports are forwarded /// ForwardedPortsCollection? ForwardedPorts { get; } /// /// An event which fires when a connection is made to the forwarded port. /// event EventHandler? ForwardedPortConnecting; /// /// Gets or sets a value indicating whether local connections for forwarded ports are /// accepted. /// /// /// Default: true /// bool AcceptLocalConnectionsForForwardedPorts { get; set; } /// /// Gets or sets the local network interface address that the tunnel client listens on when /// accepting connections for forwarded ports. /// /// /// The default value is the loopback address (127.0.0.1). Applications may set this to the /// address indicating any interface (0.0.0.0) or to the address of a specific interface. /// The tunnel client supports both IPv4 and IPv6 when listening on either loopback or /// any interface. /// IPAddress LocalForwardingHostAddress { get; set; } /// /// Gets the connection status. /// ConnectionStatus ConnectionStatus { get; } /// /// Gets the exception that caused disconnection. /// Null if not yet connected or disconnection was caused by disposing of this object. /// Exception? DisconnectException { get; } /// /// Connects to a tunnel. /// /// Tunnel to connect to. /// Cancellation token. /// /// Once connected, tunnel ports are forwarded by the host. /// The client either needs to be logged in as the owner identity, or have /// an access token with "connect" scope for the tunnel. /// /// The tunnel was not found. /// The client does not have /// access to connect to the tunnel. /// The client failed to connect to the /// tunnel, or connected but encountered a protocol error. Task ConnectAsync(Tunnel tunnel, CancellationToken cancellation = default) => ConnectAsync(tunnel, options: null, cancellation); /// /// Connects to a tunnel. /// /// Tunnel to connect to. /// ID of the tunnel host to connect to, if there are multiple /// hosts accepting connections on the tunnel, or null to connect to a single host /// (most common). /// Cancellation token. /// /// Once connected, tunnel ports are forwarded by the host. /// The client either needs to be logged in as the owner identity, or have /// an access token with "connect" scope for the tunnel. /// /// The tunnel was not found. /// The client does not have /// access to connect to the tunnel. /// The client failed to connect to the /// tunnel, or connected but encountered a protocol error. [Obsolete("Use ConnectAsync(Tunnel, TunnelConnectionOptions, CancellationToken) instead.")] Task ConnectAsync(Tunnel tunnel, string? hostId, CancellationToken cancellation) => ConnectAsync( tunnel, new TunnelConnectionOptions { HostId = hostId }, cancellation); /// /// Connects to a tunnel. /// /// Tunnel to connect to. /// Options for the connection. /// Cancellation token. /// /// Once connected, tunnel ports are forwarded by the host. /// The client either needs to be logged in as the owner identity, or have /// an access token with "connect" scope for the tunnel. /// /// The tunnel was not found. /// The client does not have /// access to connect to the tunnel. /// The client failed to connect to the /// tunnel, or connected but encountered a protocol error. Task ConnectAsync( Tunnel tunnel, TunnelConnectionOptions? options, CancellationToken cancellation = default); /// /// Waits for the specified port to be forwarded by the remote host. /// /// Remote port to wait for. /// Cancellation token for the request /// Throws if called before the client has connected. Task WaitForForwardedPortAsync(int forwardedPort, CancellationToken cancellation); /// /// Opens a stream connected to a remote port for clients which cannot or do not want to forward local TCP ports. /// Returns null if the session gets closed, or the port is no longer forwarded by the host. /// /// /// Set to false before calling to ensure /// that forwarded tunnel ports won't get local TCP listeners. /// /// Remote port to connect to. /// Cancellation token for the request. /// A representing the result of the asynchronous operation. /// If the tunnel is not yet connected and hasn't started connecting. Task ConnectToForwardedPortAsync(int forwardedPort, CancellationToken cancellation); /// /// Sends a request to the host to refresh ports that were updated using the management API, /// and waits for the refresh to complete. /// /// Cancellation token. /// /// After calling or /// , call this method to have a /// connected client notify the host to update its cached list of ports. Any added or /// removed ports will then propagate back to the set of ports forwarded by the current /// client. After the returned task has completed, any newly added ports are usable from /// the current client. /// Task RefreshPortsAsync(CancellationToken cancellation); /// /// Event handler for refreshing the tunnel access token. /// The tunnel client will fire this event when it is not able to use the access token it got from the tunnel. /// event EventHandler? RefreshingTunnelAccessToken; /// /// Connection status changed event. /// event EventHandler? ConnectionStatusChanged; } dev-tunnels-0.0.25/cs/src/Connections/ITunnelConnector.cs000066400000000000000000000011061450757157500233160ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System.Threading; using System.Threading.Tasks; namespace Microsoft.DevTunnels.Connections; /// /// Tunnel connector. /// public interface ITunnelConnector { /// /// Connect or reconnect tunnel SSH session. /// Task ConnectSessionAsync( TunnelConnectionOptions? options, bool isReconnect, CancellationToken cancellation); } dev-tunnels-0.0.25/cs/src/Connections/ITunnelHost.cs000066400000000000000000000133431450757157500223070ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Threading; using System.Threading.Tasks; using Microsoft.DevTunnels.Contracts; using Microsoft.DevTunnels.Management; using Microsoft.DevTunnels.Ssh.Tcp.Events; namespace Microsoft.DevTunnels.Connections; /// /// Interface for a host capable of sharing local ports via a tunnel and accepting /// tunneled connections to those ports. /// public interface ITunnelHost : IAsyncDisposable { /// /// Gets the connection status. /// ConnectionStatus ConnectionStatus { get; } /// /// Gets the exception that caused disconnection. /// Null if not yet connected or disconnection was caused by disposing of this object. /// Exception? DisconnectException { get; } /// /// A value indicating whether the port-forwarding service forwards connections to local TCP sockets. /// /// /// The default value is true. /// bool ForwardConnectionsToLocalPorts { get; set; } /// /// Connects to a tunnel as a host and starts accepting incoming connections /// to local ports as defined on the tunnel. /// /// Information about the tunnel to connect to. /// Cancellation token. /// /// The host either needs to be logged in as the owner identity, or have /// an access token with "host" scope for the tunnel. /// /// The tunnel was not found. /// The host does not have /// access to host the tunnel. /// The host failed to connect to the /// tunnel, or connected but encountered a protocol error. [Obsolete("Use ConnectAsync() instead.")] Task StartAsync(Tunnel tunnel, CancellationToken cancellation) => ConnectAsync(tunnel, options: null, cancellation); /// /// Connects to a tunnel as a host and starts accepting incoming connections /// to local ports as defined on the tunnel. /// /// Information about the tunnel to connect to. /// Cancellation token. /// /// The host either needs to be logged in as the owner identity, or have /// an access token with "host" scope for the tunnel. /// /// The tunnel was not found. /// The host does not have /// access to host the tunnel. /// The host failed to connect to the /// tunnel, or connected but encountered a protocol error. Task ConnectAsync(Tunnel tunnel, CancellationToken cancellation = default) => ConnectAsync(tunnel, options: null, cancellation); /// /// Connects to a tunnel as a host and starts accepting incoming connections /// to local ports as defined on the tunnel. /// /// Information about the tunnel to connect to. /// Options for the connection. /// Cancellation token. /// /// The host either needs to be logged in as the owner identity, or have /// an access token with "host" scope for the tunnel. /// /// The tunnel was not found. /// The host does not have /// access to host the tunnel. /// The host failed to connect to the /// tunnel, or connected but encountered a protocol error. Task ConnectAsync( Tunnel tunnel, TunnelConnectionOptions? options, CancellationToken cancellation = default); /// /// Refreshes ports that were updated using the management API. /// /// Cancellation token. /// /// After calling or /// , call this method to have the /// host update its cached list of ports. Any added or removed ports will then propagate to /// the set of ports forwarded by all connected clients. /// Task RefreshPortsAsync(CancellationToken cancellation); /// /// Event handler for refreshing the tunnel access token. /// The tunnel client will fire this event when it is not able to use the access token it got from the tunnel. /// event EventHandler? RefreshingTunnelAccessToken; /// /// Connection status changed event. /// event EventHandler? ConnectionStatusChanged; /// /// An event which fires when a connection is made to the forwarded port. /// /// /// Set to false if a local TCP socket /// should not be created for the connection stream. When this is set only the /// ForwardedPortConnecting event will be raised. /// event EventHandler? ForwardedPortConnecting; } dev-tunnels-0.0.25/cs/src/Connections/ITunnelRelayStreamFactory.cs000066400000000000000000000032231450757157500251460ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.IO; using System.Threading; using System.Threading.Tasks; namespace Microsoft.DevTunnels.Connections { /// /// Interface for a factory capable of creating streams to a tunnel relay. /// /// /// Normally the default can be used. However a /// different factory class may be used to customize the connection (or mock the connection /// for testing). /// /// /// public interface ITunnelRelayStreamFactory { /// /// Creates a stream connected to a tunnel relay URI. /// /// URI of the tunnel relay to connect to. /// Tunnel host access token, or null if anonymous. /// One or more websocket subprotocols (relay connection /// protocols). /// Cancellation token. /// Stream connected to the relay, along with the actual subprotocol that was /// selected by the server. Task<(Stream Stream, string SubProtocol)> CreateRelayStreamAsync( Uri relayUri, string? accessToken, string[] subprotocols, CancellationToken cancellation); } } dev-tunnels-0.0.25/cs/src/Connections/Messages/000077500000000000000000000000001450757157500213075ustar00rootroot00000000000000dev-tunnels-0.0.25/cs/src/Connections/Messages/PortRelayConnectRequestMessage.cs000066400000000000000000000036461450757157500277600ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Text; using Microsoft.DevTunnels.Ssh.Messages; using Microsoft.DevTunnels.Ssh.IO; namespace Microsoft.DevTunnels.Connections.Messages; /// /// Extends port-forward channel open messages to include additional properties required /// by the tunnel relay. /// public class PortRelayConnectRequestMessage : PortForwardChannelOpenMessage { /// /// Access token with 'connect' scope used to authorize the port connection request. /// /// /// A long-running client may need handle the /// event to refresh the access token /// before opening additional connections (channels) to forwarded ports. /// public string? AccessToken { get; set; } /// /// Gets or sets a value indicating whether end-to-end encryption is requested for the /// connection. /// /// /// The tunnel relay or tunnel host may enable E2E encryption or not depending on capabilities /// and policies. The channel open response will indicate whether E2E encryption is actually /// enabled for the connection. /// public bool IsE2EEncryptionRequested { get; set; } /// protected override void OnWrite(ref SshDataWriter writer) { base.OnWrite(ref writer); writer.Write(AccessToken ?? string.Empty, Encoding.UTF8); writer.Write(IsE2EEncryptionRequested); } /// protected override void OnRead(ref SshDataReader reader) { base.OnRead(ref reader); AccessToken = reader.ReadString(Encoding.UTF8); IsE2EEncryptionRequested = reader.ReadBoolean(); } } dev-tunnels-0.0.25/cs/src/Connections/Messages/PortRelayConnectResponseMessage.cs000066400000000000000000000027121450757157500301170ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using Microsoft.DevTunnels.Ssh.Messages; using Microsoft.DevTunnels.Ssh.IO; namespace Microsoft.DevTunnels.Connections.Messages; /// /// Extends port-forward channel open confirmation messages to include additional properties /// required by the tunnel relay. /// public class PortRelayConnectResponseMessage : ChannelOpenConfirmationMessage { /// /// Gets or sets a value indicating whether end-to-end encryption is enabled for the /// connection. /// /// /// The tunnel client may request E2E encryption via /// . Then relay or host /// may enable E2E encryption or not depending on capabilities and policies, and the resulting /// enabled status is returned to the client via this property. /// public bool IsE2EEncryptionEnabled { get; set; } /// protected override void OnWrite(ref SshDataWriter writer) { base.OnWrite(ref writer); writer.Write(IsE2EEncryptionEnabled); } /// protected override void OnRead(ref SshDataReader reader) { base.OnRead(ref reader); IsE2EEncryptionEnabled = reader.ReadBoolean(); } } dev-tunnels-0.0.25/cs/src/Connections/Messages/PortRelayRequestMessage.cs000066400000000000000000000025671450757157500264470ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Text; using Microsoft.DevTunnels.Ssh.Messages; using Microsoft.DevTunnels.Ssh.IO; namespace Microsoft.DevTunnels.Connections.Messages; /// /// Extends port-forward request messagse to include additional properties required /// by the tunnel relay. /// public class PortRelayRequestMessage : PortForwardRequestMessage { /// /// Access token with 'host' scope used to authorize the port-forward request. /// /// /// A long-running host may need to handle the /// event to refresh the access token /// before forwarding additional ports. /// public string? AccessToken { get; set; } /// protected override void OnWrite(ref SshDataWriter writer) { base.OnWrite(ref writer); writer.Write( AccessToken ?? throw new InvalidOperationException("An access token is required."), Encoding.UTF8); } /// protected override void OnRead(ref SshDataReader reader) { base.OnRead(ref reader); AccessToken = reader.ReadString(Encoding.UTF8); } } dev-tunnels-0.0.25/cs/src/Connections/MultiModeTunnelClient.cs000066400000000000000000000104231450757157500243130ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net; using System.Threading; using System.Threading.Tasks; using Microsoft.DevTunnels.Ssh.Tcp.Events; using Microsoft.DevTunnels.Contracts; using Microsoft.DevTunnels.Management; namespace Microsoft.DevTunnels.Connections; /// /// Tunnel client implementation that selects one of multiple available connection modes. /// public class MultiModeTunnelClient : TunnelConnection, ITunnelClient { /// /// Creates a new instance of the class /// that can select from among multiple single-mode clients. /// public MultiModeTunnelClient(IEnumerable clients, ITunnelManagementClient managementClient, TraceSource trace) : base(managementClient, trace) { Clients = new List(Requires.NotNull(clients, nameof(clients))); Requires.Argument( Clients.Count() > 0, nameof(clients), "At least one tunnel client is required."); // TODO: Subscribe to clients RefreshingTunnelAccessToken event and call TunnelBase.RefreshTunnelAccessTokenAsync() to get the tunnel access token. } /// /// Gets the list of clients that may be used to connect to the tunnel. /// public IEnumerable Clients { get; } /// public IReadOnlyCollection ConnectionModes => Clients.SelectMany((c) => c.ConnectionModes).Distinct().ToArray(); /// public ForwardedPortsCollection? ForwardedPorts => throw new NotImplementedException(); #pragma warning disable CS0067 // Not used /// public event EventHandler? ForwardedPortConnecting; #pragma warning restore CS0067 /// public bool AcceptLocalConnectionsForForwardedPorts { get => Clients.Any(c => c.AcceptLocalConnectionsForForwardedPorts); set { foreach (var client in Clients) { client.AcceptLocalConnectionsForForwardedPorts = value; } } } /// public IPAddress LocalForwardingHostAddress { get => Clients.FirstOrDefault()?.LocalForwardingHostAddress ?? IPAddress.Loopback; set { foreach (var client in Clients) { client.LocalForwardingHostAddress = value; } } } /// protected override string TunnelAccessScope => TunnelAccessScopes.Connect; /// public override async Task ConnectAsync( Tunnel tunnel, TunnelConnectionOptions? options, CancellationToken cancellation = default) { Requires.NotNull(tunnel, nameof(tunnel)); // TODO: Filter tunnel endpoints by host ID, if specified. // TODO: Match tunnel endpoints to client connection modes. await Task.CompletedTask; throw new NotImplementedException(); } /// public Task WaitForForwardedPortAsync(int forwardedPort, CancellationToken cancellation) { throw new NotImplementedException(); } /// public override async ValueTask DisposeAsync() { await base.DisposeAsync(); var disposeTasks = new List(); foreach (var client in Clients) { disposeTasks.Add(client.DisposeAsync().AsTask()); } await Task.WhenAll(disposeTasks); } /// public Task ConnectToForwardedPortAsync(int forwardedPort, CancellationToken cancellation) { throw new NotImplementedException(); } /// protected override Task CreateTunnelConnectorAsync(CancellationToken cancellation) { throw new NotImplementedException(); } /// public Task RefreshPortsAsync(CancellationToken cancellation) { throw new NotImplementedException(); } } dev-tunnels-0.0.25/cs/src/Connections/MultiModeTunnelHost.cs000066400000000000000000000075401450757157500240200ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.DevTunnels.Contracts; using Microsoft.DevTunnels.Management; using Microsoft.DevTunnels.Ssh.Tcp.Events; namespace Microsoft.DevTunnels.Connections { /// /// Aggregation of multiple tunnel hosts. /// public class MultiModeTunnelHost : TunnelConnection, ITunnelHost { /// /// Gets or sets a host ID. An initial value is automatically generated for the process. /// /// /// The host ID uniquely identifies one host process that is accepting connections on a /// tunnel. If the host supports multiple connection modes, the host's ID is the same for /// all the endpoints it supports. /// public static string HostId { get; set; } = Guid.NewGuid().ToString(); /// /// Creates a new instance of the class /// that can simultaneously run multiple single-mode hosts. /// public MultiModeTunnelHost(IEnumerable hosts, ITunnelManagementClient managementClient, TraceSource trace) : base(managementClient, trace) { Hosts = new List(Requires.NotNull(hosts, nameof(hosts))); // TODO: Subscribe to hosts' RefreshingTunnelAccessToken event and call TunnelBase.RefreshTunnelAccessTokenAsync() to get the tunnel access token. } /// /// Gets the list of hosts that can accept connections on the tunnel. /// public IEnumerable Hosts { get; } /// protected override string TunnelAccessScope => TunnelAccessScopes.Host; /// public bool ForwardConnectionsToLocalPorts { get => Hosts.Any(c => c.ForwardConnectionsToLocalPorts); set { foreach (var host in Hosts) { host.ForwardConnectionsToLocalPorts = value; } } } #pragma warning disable CS0067 // Not used /// public event EventHandler? ForwardedPortConnecting; #pragma warning restore CS0067 /// public override async Task ConnectAsync( Tunnel tunnel, TunnelConnectionOptions? options, CancellationToken cancellation = default) { Requires.NotNull(tunnel, nameof(tunnel)); var startTasks = new List(); foreach (var host in Hosts) { startTasks.Add(host.ConnectAsync(tunnel, options, cancellation)); } await Task.WhenAll(startTasks); } /// public override async ValueTask DisposeAsync() { await base.DisposeAsync(); var disposeTasks = new List(); foreach (var host in Hosts) { disposeTasks.Add(host.DisposeAsync().AsTask()); } await Task.WhenAll(disposeTasks); } /// protected override Task CreateTunnelConnectorAsync(CancellationToken cancellation) { throw new NotImplementedException(); } /// public async Task RefreshPortsAsync(CancellationToken cancellation) { await Task.WhenAll( Hosts.Select((c) => c.RefreshPortsAsync(cancellation))); } } } dev-tunnels-0.0.25/cs/src/Connections/RefreshingTunnelAccessTokenEventArgs.cs000066400000000000000000000026441450757157500273210ustar00rootroot00000000000000ďťż// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Threading; using System.Threading.Tasks; namespace Microsoft.DevTunnels.Connections; /// /// Event args for tunnel access token refresh event. /// public class RefreshingTunnelAccessTokenEventArgs : EventArgs { /// /// Creates a new instance of class. /// public RefreshingTunnelAccessTokenEventArgs(string tunnelAccessScope, CancellationToken cancellation) { TunnelAccessScope = Requires.NotNull(tunnelAccessScope, nameof(tunnelAccessScope)); Cancellation = cancellation; } /// /// Tunnel access scope to get the token for. /// public string TunnelAccessScope { get; } /// /// Cancellation token that event handler may observe when it asynchronously fetches the tunnel access token. /// public CancellationToken Cancellation { get; } /// /// Token task the event handler may set to asynchnronously fetch the token. /// The result of the task may be a new tunnel access token or null if it couldn't get the token. /// public Task? TunnelAccessTokenTask { get; set; } } dev-tunnels-0.0.25/cs/src/Connections/RelayTunnelConnector.cs000066400000000000000000000270711450757157500242130ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Diagnostics; using System.IO; using System.Net; using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; using Microsoft.DevTunnels.Management; using Microsoft.DevTunnels.Ssh; namespace Microsoft.DevTunnels.Connections; /// /// Tunnel connector that connects to Tunnel Relay service. /// internal sealed class RelayTunnelConnector : ITunnelConnector { private const int RetryMaxDelayMs = 12_800; // After the 6th attempt the delay will reach 2^7 * 100ms = 12.8s and stop doubling private const int RetryInitialDelayMs = 100; private readonly IRelayClient relayClient; public RelayTunnelConnector(IRelayClient relayClient) { this.relayClient = Requires.NotNull(relayClient, nameof(relayClient)); Requires.NotNull(relayClient.Trace, nameof(IRelayClient.Trace)); } private TraceSource Trace => this.relayClient.Trace; /// public async Task ConnectSessionAsync( TunnelConnectionOptions? options, bool isReconnect, CancellationToken cancellation) { int attempt = 0; int attemptDelayMs = RetryInitialDelayMs; bool isTunnelAccessTokenRefreshed = false; bool isDelayNeeded; string? errorDescription; SshDisconnectReason disconnectReason; Exception? exception; while (true) { cancellation.ThrowIfCancellationRequested(); attempt++; isDelayNeeded = true; Stream? stream = null; errorDescription = null; disconnectReason = SshDisconnectReason.ConnectionLost; exception = null; try { // TODO: check tunnel access token expiration and try refresh it if it's expired. stream = await this.relayClient.CreateSessionStreamAsync(cancellation); await this.relayClient.ConfigureSessionAsync(stream, isReconnect, cancellation); stream = null; disconnectReason = SshDisconnectReason.None; return; } catch (UnauthorizedAccessException uaex) // Tunnel access token validation failed. { await RefreshTunnelAccessTokenAsync(uaex); } catch (SshReconnectException srex) { errorDescription = srex.Message; disconnectReason = SshDisconnectReason.ProtocolError; exception = srex; isDelayNeeded = false; isReconnect = false; } catch (SshConnectionException scex) when (scex.DisconnectReason == SshDisconnectReason.ConnectionLost) { // Recoverable errorDescription = scex.Message; } catch (SshConnectionException scex) { // All other SSH connection exceptions are not recoverable. disconnectReason = scex.DisconnectReason != SshDisconnectReason.None ? scex.DisconnectReason : SshDisconnectReason.ByApplication; throw exception = new TunnelConnectionException($"Failed to start tunnel SSH session: {scex.Message}", scex); } catch (WebSocketException wse) { if (wse.WebSocketErrorCode == WebSocketError.UnsupportedProtocol) { throw exception = new InvalidOperationException("Unsupported web socket sub-protocol.", wse); } if (wse.WebSocketErrorCode == WebSocketError.NotAWebSocket) { var statusCode = TunnelConnectionException.GetHttpStatusCode(wse); switch (statusCode) { case HttpStatusCode.Unauthorized: // Unauthorized error may happen when the tunnel access token is no longer valid, e.g. expired. // Try refreshing it. await RefreshTunnelAccessTokenAsync(wse); exception = new UnauthorizedAccessException( $"Unauthorized (401). Provide a fresh tunnel access token with '{this.relayClient.TunnelAccessScope}' scope.", wse); break; case HttpStatusCode.Forbidden: throw exception = new UnauthorizedAccessException( $"Forbidden (403). Provide a fresh tunnel access token with '{this.relayClient.TunnelAccessScope}' scope.", wse); case HttpStatusCode.NotFound: throw exception = new TunnelConnectionException($"The tunnel or port is not found (404).", wse); case HttpStatusCode.TooManyRequests: errorDescription = "Rate limit exceeded (429). Too many requests in a given amount of time."; exception = new TunnelConnectionException(errorDescription, wse); if (attempt > 4) { throw exception; } if (attemptDelayMs < RetryMaxDelayMs) { attemptDelayMs <<= 1; } Trace.Info($"Rate limit exceeded. Delaying for {attemptDelayMs/1000.0}s before retrying."); break; case HttpStatusCode.ServiceUnavailable: // Normally nginx choses another healthy pod when it encounters 503. // However, if there are no other pods, it returns 503 to the client. // This rare case may happen when the cluster recovers from a failure // and the nginx controller has started but Relay service has not yet. errorDescription = "Service temporarily unavailable (503)."; exception = new TunnelConnectionException(errorDescription, wse); break; default: // For any other status code we assume the error is not recoverable. throw exception = new TunnelConnectionException(wse.Message, wse); } } // Other web socket errors may be recoverable exception ??= wse; errorDescription ??= wse.Message; } catch (OperationCanceledException) { disconnectReason = SshDisconnectReason.ByApplication; throw; } catch (Exception ex) { // These exceptions are not recoverable if (ex is InvalidOperationException || ex is ObjectDisposedException || ex is NotSupportedException || ex is NotImplementedException || ex is NullReferenceException || ex is ArgumentNullException || ex is ArgumentException) { throw; } errorDescription = ex.Message; exception = ex; } finally { if (disconnectReason != SshDisconnectReason.None) { await this.relayClient.CloseSessionAsync(disconnectReason, exception); } if (stream != null) { await stream.DisposeAsync(); } } if (exception != null) { if (options?.EnableRetry == false) { throw exception; } var retryDelay = TimeSpan.FromMilliseconds(isDelayNeeded ? attemptDelayMs : 0); var retryingArgs = new RetryingTunnelConnectionEventArgs(exception, retryDelay); this.relayClient.OnRetrying(retryingArgs); if (!retryingArgs.Retry) { throw exception; } else if ((int)retryingArgs.Delay.TotalMilliseconds > 0) { attemptDelayMs = (int)retryingArgs.Delay.TotalMilliseconds; isDelayNeeded = true; } else { isDelayNeeded = false; } } var retryTiming = isDelayNeeded ? $" in {(attemptDelayMs < 1000 ? $"0.{attemptDelayMs / 100}s" : $"{attemptDelayMs / 1000}s")}" : string.Empty; Trace.Verbose($"Error connecting to tunnel SSH session, retrying{retryTiming}{(errorDescription != null ? $": {errorDescription}" : string.Empty)}"); if (isDelayNeeded) { await Task.Delay(attemptDelayMs, cancellation); if (attemptDelayMs < RetryMaxDelayMs) { attemptDelayMs <<= 1; } } async Task RefreshTunnelAccessTokenAsync(Exception exception) { var statusCode = TunnelConnectionException.GetHttpStatusCode(exception); var statusCodeText = statusCode != default ? $" ({(int)statusCode})" : string.Empty; if (!isTunnelAccessTokenRefreshed) { try { isTunnelAccessTokenRefreshed = await this.relayClient.RefreshTunnelAccessTokenAsync(cancellation); } catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { throw; } catch (UnauthorizedAccessException uaex) { // The refreshed token is not valid. throw new UnauthorizedAccessException( $"Not authorized{statusCode}. Refreshed tunnel access token is not valid. {uaex.Message}", uaex); } catch (Exception ex) { throw new UnauthorizedAccessException( $"Not authorized{statusCode}. Refreshing tunnel access token failed with error {ex.Message}", ex); } if (isTunnelAccessTokenRefreshed) { isDelayNeeded = false; errorDescription = "The tunnel access token was no longer valid and had just been refreshed."; return; } } if (exception is UnauthorizedAccessException) { throw exception; } throw new UnauthorizedAccessException( "Not authorized (401). " + (isTunnelAccessTokenRefreshed ? "Refreshed tunnel access token also doesn't work." : $"Provide a fresh tunnel access token with '{this.relayClient.TunnelAccessScope}' scope."), exception); } } } } dev-tunnels-0.0.25/cs/src/Connections/RetryTcpListenerFactory.cs000066400000000000000000000070261450757157500247060ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Diagnostics; using System.Net; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using Microsoft.DevTunnels.Ssh.Tcp; namespace Microsoft.DevTunnels.Connections; /// /// Implementation of a TCP listener factory that retries forwarding with nearby ports and falls back to a random port. /// We make the assumption that the remote port that is being connected to and localPort numbers are the same. /// internal class RetryTcpListenerFactory : ITcpListenerFactory { public RetryTcpListenerFactory(IPAddress localAddress) { LocalAddress = localAddress; if (localAddress == IPAddress.Loopback) { LocalAddressV6 = IPAddress.IPv6Loopback; } else if (localAddress == IPAddress.Any) { LocalAddressV6 = IPAddress.IPv6Any; } } public IPAddress LocalAddress { get; } public IPAddress? LocalAddressV6 { get; } /// public Task CreateTcpListenerAsync( IPAddress localIPAddress, int localPort, bool canChangePort, TraceSource trace, CancellationToken cancellation) { const ushort maxOffet = 10; TcpListener listener; // The SSH protocol may specify a local IP address for forwarding, but that is ignored // by tunnels. Instead, the tunnel client can specify the local IP address. if (localIPAddress.AddressFamily == AddressFamily.InterNetwork && localIPAddress != LocalAddress) { trace.TraceInformation( $"Using local interface address {LocalAddress} instead of {localIPAddress}."); localIPAddress = LocalAddress; } else if (localIPAddress.AddressFamily == AddressFamily.InterNetworkV6 && LocalAddressV6 != null && localIPAddress != LocalAddressV6) { trace.TraceInformation( $"Using local interface address {LocalAddressV6} instead of {localIPAddress}."); localIPAddress = LocalAddressV6; } for (ushort offset = 0; ; offset++) { // After reaching the max offset, pass 0 to pick a random available port. var localPortNumber = offset == maxOffet ? 0 : localPort + offset; try { listener = new TcpListener(localIPAddress, localPortNumber); listener.Server.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, false); listener.Start(); // It is assumed that the localPort passed in is the same as the host port trace.TraceInformation($"Forwarding from {listener.LocalEndpoint} to host port {localPort}."); return Task.FromResult(listener); } catch (SocketException sockex) when ((sockex.SocketErrorCode == SocketError.AccessDenied || sockex.SocketErrorCode == SocketError.AddressAlreadyInUse) && offset < maxOffet && canChangePort) { trace.TraceEvent(TraceEventType.Verbose, 1, "Listening on port " + localPortNumber + " failed: " + sockex.Message); trace.TraceEvent(TraceEventType.Verbose, 2, "Incrementing port and trying again"); continue; } } } } dev-tunnels-0.0.25/cs/src/Connections/RetryingTunnelConnectionEventArgs.cs000066400000000000000000000024431450757157500267220ustar00rootroot00000000000000ďťż// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; namespace Microsoft.DevTunnels.Connections; /// /// Event args for tunnel connection retry event. /// public class RetryingTunnelConnectionEventArgs : EventArgs { /// /// Creates a new instance of class. /// public RetryingTunnelConnectionEventArgs(Exception exception, TimeSpan delay) { Exception = Requires.NotNull(exception, nameof(exception)); Retry = true; Delay = delay; } /// /// Gets the exception that caused the retry. /// /// /// For an au /// public Exception Exception { get; } /// /// Gets the amount of time to wait before retrying. An event handler may change this value /// to adjust the delay. /// public TimeSpan Delay { get; set; } /// /// Gets or sets a value indicating whether the retry will proceed. An event handler may /// set this to false to stop retrying. /// public bool Retry { get; set; } } dev-tunnels-0.0.25/cs/src/Connections/SessionPortKey.cs000066400000000000000000000032071450757157500230320ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Collections; using System.Collections.Generic; using System.Linq; namespace Microsoft.DevTunnels.Connections; /// /// Class for comparing equality in SSH session ID port pairs. /// /// /// This class is public for testing purposes, and may be removed in the future. /// public class SessionPortKey { /// /// Session ID of the client SSH session, or null if the session does not have an ID /// (because it is not encrypted and not client-specific). /// public byte[]? SessionId { get; } /// /// Forwarded port number. /// public ushort Port { get; } /// /// Creates a new instance of the SessionPortKey class. /// public SessionPortKey(byte[]? sessionId, ushort port) { this.SessionId = sessionId; this.Port = port; } /// public override bool Equals(object? obj) => obj is SessionPortKey other && other.Port == this.Port && ((this.SessionId == null && other.SessionId == null) || ((this.SessionId != null && other.SessionId != null) && Enumerable.SequenceEqual(other.SessionId, this.SessionId))); /// public override int GetHashCode() => HashCode.Combine( this.Port, this.SessionId == null ? 0 : ((IStructuralEquatable)this.SessionId).GetHashCode(EqualityComparer.Default)); } dev-tunnels-0.0.25/cs/src/Connections/TunnelClient.cs000066400000000000000000000626111450757157500225010ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Net; using System.Linq; using System.Security.Claims; using System.Threading; using System.Threading.Tasks; using Microsoft.DevTunnels.Ssh; using Microsoft.DevTunnels.Ssh.Events; using Microsoft.DevTunnels.Ssh.Messages; using Microsoft.DevTunnels.Ssh.Tcp; using Microsoft.DevTunnels.Ssh.Tcp.Events; using Microsoft.DevTunnels.Contracts; using Microsoft.DevTunnels.Management; using Microsoft.DevTunnels.Connections.Messages; namespace Microsoft.DevTunnels.Connections; /// /// Base class for clients that connect to a single host /// public abstract class TunnelClient : TunnelConnection, ITunnelClient { private bool acceptLocalConnectionsForForwardedPorts = true; private IPAddress localForwardingHostAddress = IPAddress.Loopback; private readonly Dictionary> disconnectedStreams = new(); /// /// Creates a new instance of the class. /// public TunnelClient(ITunnelManagementClient? managementClient, TraceSource trace) : base(managementClient, trace) { } /// public abstract IReadOnlyCollection ConnectionModes { get; } /// public ForwardedPortsCollection? ForwardedPorts => SshPortForwardingService?.RemoteForwardedPorts; /// /// Connection protocol used to connect to host. /// public string? ConnectionProtocol { get; protected set; } /// /// Session used to connect to host /// protected SshClientSession? SshSession { get; set; } /// /// One or more SSH public keys published by the host with the tunnel endpoint. /// protected string[]? HostPublicKeys { get; set; } /// /// Port forwarding service on . /// protected PortForwardingService? SshPortForwardingService { get; private set; } /// /// A value indicating whether the SSH session is active. /// protected bool IsSshSessionActive { get; private set; } /// protected override string TunnelAccessScope => TunnelAccessScopes.Connect; /// public event EventHandler? ForwardedPortConnecting; /// /// Get a value indicating if remote is forwarded and has any channels open on the client, /// whether used by local tcp listener if is true, or /// streamed via . /// protected bool HasForwardedChannels(int port) => IsSshSessionActive && SshPortForwardingService!.RemoteForwardedPorts.FirstOrDefault(p => p.RemotePort == port) is ForwardedPort forwardedPort && SshPortForwardingService!.RemoteForwardedPorts.GetChannels(forwardedPort).Any(); /// /// SSH session closed event. /// The client may try to reconnect after firing this event. /// protected EventHandler? SshSessionClosed { get; set; } /// /// Gets or sets a value indicating whether local connections for forwarded ports are /// accepted. /// public bool AcceptLocalConnectionsForForwardedPorts { get => this.acceptLocalConnectionsForForwardedPorts; set { if (value != this.acceptLocalConnectionsForForwardedPorts) { this.acceptLocalConnectionsForForwardedPorts = value; ConfigurePortForwardingService(); } } } /// /// Gets or sets the local network interface address that the tunnel client listens on when /// accepting connections for forwarded ports. /// /// /// The default value is the loopback address (127.0.0.1). Applications may set this to the /// address indicating any interface (0.0.0.0) or to the address of a specific interface. /// The tunnel client supports both IPv4 and IPv6 when listening on either loopback or /// any interface. /// public IPAddress LocalForwardingHostAddress { get => this.localForwardingHostAddress; set { if (value != this.localForwardingHostAddress) { this.localForwardingHostAddress = value; ConfigurePortForwardingService(); } } } /// /// Get host Id the client is connecting to. /// public string? HostId { get; private set; } /// public override async Task ConnectAsync( Tunnel tunnel, TunnelConnectionOptions? options, CancellationToken cancellation = default) { Requires.NotNull(tunnel, nameof(tunnel)); Requires.NotNull(tunnel.Endpoints!, nameof(Tunnel.Endpoints)); if (this.SshSession != null) { throw new InvalidOperationException( "Already connected. Use separate instances to connect to multiple tunnels."); } if (tunnel.Endpoints.Length == 0) { throw new InvalidOperationException( "No hosts are currently accepting connections for the tunnel."); } HostId = options?.HostId; await ConnectTunnelSessionAsync(tunnel, options, cancellation); } /// public Task WaitForForwardedPortAsync(int forwardedPort, CancellationToken cancellation) => SshPortForwardingService is PortForwardingService pfs ? pfs.WaitForForwardedPortAsync(forwardedPort, cancellation) : throw new InvalidOperationException("Port forwarding has not been started. Ensure that the client has connected by calling ConnectAsync."); /// /// Start SSH session on the . /// /// /// Overwrites property. /// SSH session reconnect is enabled only if is not null. /// protected async Task StartSshSessionAsync(Stream stream, CancellationToken cancellation) { ConnectionStatus = ConnectionStatus.Connecting; var session = this.SshSession; if (session != null) { // Unsubscribe event handler from the previous session. session.Authenticating -= OnSshServerAuthenticating; session.Disconnected -= OnSshSessionDisconnected; session.Closed -= OnSshSessionClosed; session.Request -= OnRequest; } // Enable V1 reconnect only if connector is set as reconnect depends on it. // (V2 SSH reconnect is handled by the SecureStream class.) var clientConfig = new SshSessionConfiguration( enableReconnect: this.connector != null && ConnectionProtocol == TunnelRelayTunnelClient.WebSocketSubProtocol); if (ConnectionProtocol == TunnelRelayTunnelClient.WebSocketSubProtocolV2) { // Configure optional encryption, including "none" as an enabled and preferred kex algorithm, // because encryption of the outer SSH session is optional since it is already over a TLS websocket. clientConfig.KeyExchangeAlgorithms.Clear(); clientConfig.KeyExchangeAlgorithms.Add(SshAlgorithms.KeyExchange.None); clientConfig.KeyExchangeAlgorithms.Add(SshAlgorithms.KeyExchange.EcdhNistp384); clientConfig.KeyExchangeAlgorithms.Add(SshAlgorithms.KeyExchange.EcdhNistp256); clientConfig.KeyExchangeAlgorithms.Add(SshAlgorithms.KeyExchange.DHGroup16Sha512); clientConfig.KeyExchangeAlgorithms.Add(SshAlgorithms.KeyExchange.DHGroup14Sha256); } // Enable port-forwarding via the SSH protocol. clientConfig.AddService(typeof(PortForwardingService)); session = new SshClientSession(clientConfig, Trace.WithName("SSH")); this.SshSession = session; session.Authenticating += OnSshServerAuthenticating; session.Disconnected += OnSshSessionDisconnected; session.Closed += OnSshSessionClosed; SshPortForwardingService = session.ActivateService(); ConfigurePortForwardingService(); session.Request += OnRequest; SshSessionCreated(); await session.ConnectAsync(stream, cancellation); // SSH authentication is required in V1 protocol, optional in V2 depending on whether the // session enabled key exchange (as indicated by having a session ID or not). In either case // a password is not required. Strong authentication was already handled by the relay // service via the tunnel access token used for the websocket connection. if (session.SessionId != null) { var clientCredentials = new SshClientCredentials("tunnel", password: null); if (!await session.AuthenticateAsync(clientCredentials)) { // Server authentication happens first, and if it succeeds then it sets a principal. throw new SshConnectionException( session.Principal == null ? "SSH server authentication failed." : "SSH client authentication failed."); } } ConnectionStatus = ConnectionStatus.Connected; } private void OnSshSessionDisconnected(object? sender, EventArgs e) => StartReconnectTaskIfNotDisposed(); private void ConfigurePortForwardingService() { var pfs = SshPortForwardingService; if (pfs == null) { return; } pfs.AcceptLocalConnectionsForForwardedPorts = this.acceptLocalConnectionsForForwardedPorts; if (pfs.AcceptLocalConnectionsForForwardedPorts) { pfs.TcpListenerFactory = new RetryTcpListenerFactory(this.localForwardingHostAddress); } if (ConnectionProtocol == TunnelRelayTunnelClient.WebSocketSubProtocolV2) { pfs.MessageFactory = this; pfs.ForwardedPortConnecting += OnForwardedPortConnecting; pfs.RemoteForwardedPorts.PortAdded += (_, e) => OnForwardedPortAdded(pfs, e); pfs.RemoteForwardedPorts.PortUpdated += (_, e) => OnForwardedPortAdded(pfs, e); } } private void OnForwardedPortAdded(PortForwardingService pfs, ForwardedPortEventArgs e) { var port = e.Port.RemotePort; if (!port.HasValue) { return; } List? streams; lock (this.disconnectedStreams) { // If there are disconnected streams for the port, re-connect them now. if (!this.disconnectedStreams.TryGetValue(port.Value, out streams)) { streams = null; } } if (streams?.Count > 0) { this.Trace.Verbose( $"Reconnecting {streams.Count} stream(s) to forwarded port {port}"); for (int i = streams.Count; i > 0; i--) { Task.Run(async () => { try { await pfs.ConnectToForwardedPortAsync(port.Value, CancellationToken.None); this.Trace.Verbose($"Reconnected stream to forwarded port {port}"); } catch (Exception ex) { this.Trace.Warning( $"Failed to reconnect to forwarded port {port}: {ex.Message}"); lock (this.disconnectedStreams) { // The host is no longer accepting connections on the forwarded port? // Dispose and clear the list of disconnected streams for the port, // because it seems it is no longer possible to reconnect them. while (streams.Count > 0) { streams[0].Dispose(); streams.RemoveAt(0); } } } }); } } } /// /// Invoked when a forwarded port is connecting. (Only for V2 protocol.) /// protected virtual void OnForwardedPortConnecting( object? sender, ForwardedPortConnectingEventArgs e) { // With V2 protocol, the relay server always sends an extended response message // with a property indicating whether E2E encryption is enabled for the connection. var channel = e.Stream.Channel; var relayResponseMessage = channel.OpenConfirmationMessage .ConvertTo(); if (relayResponseMessage.IsE2EEncryptionEnabled) { // The host trusts the relay to authenticate the client, so it doesn't require // any additional password/token for client authentication. var clientCredentials = new SshClientCredentials("tunnel"); e.TransformTask = EncryptChannelAsync(e.Stream); async Task EncryptChannelAsync(SshStream channelStream) { SecureStream? secureStream = null; // If there's a disconnected SecureStream for the port, try to reconnect it. // If there are multiple, pick one and the host will match by SSH session ID. lock (this.disconnectedStreams) { if (this.disconnectedStreams.TryGetValue(e.Port, out var streamsList) && streamsList.Count > 0) { secureStream = streamsList[0]; streamsList.RemoveAt(0); } } var trace = channel.Trace.WithName(channel.Trace.Name + "." + channel.ChannelId); if (secureStream != null) { trace.Verbose($"Reconnecting encrypted stream for port {e.Port}..."); await secureStream.ReconnectAsync(channelStream); trace.Verbose($"Reconnecting encrypted stream for port {e.Port} succeeded."); } else { secureStream = new SecureStream( e.Stream, clientCredentials, enableReconnect: true, trace); secureStream.Authenticating += OnHostAuthenticating; secureStream.Disconnected += (_, _) => OnSecureStreamDisconnected( e.Port, secureStream, trace); // Do not pass the cancellation token from the connecting event, // because the connection will outlive the event. await secureStream.ConnectAsync(); } return secureStream; } } this.ForwardedPortConnecting?.Invoke(this, e); } private void OnSecureStreamDisconnected(int port, SecureStream secureStream, TraceSource trace) { trace.Verbose($"Encrypted stream for port {port} disconnected."); lock (this.disconnectedStreams) { if (this.disconnectedStreams.TryGetValue(port, out var streamsList)) { streamsList.Add(secureStream); } else { this.disconnectedStreams.Add(port, new List { secureStream }); } } } private void OnHostAuthenticating(object? sender, SshAuthenticatingEventArgs e) { // If this method returns without assigning e.AuthenticationTask, the auth fails. if (e.AuthenticationType != SshAuthenticationType.ServerPublicKey || e.PublicKey == null) { this.Trace.Warning("Invalid host authenticating event."); return; } // The public key property on this event comes from SSH key-exchange; at this point the // SSH server has cryptographically proven that it holds the corresponding private key. // Convert host key bytes to base64 to match the format in which the keys are published. var hostKey = e.PublicKey.GetPublicKeyBytes(e.PublicKey.KeyAlgorithmName).ToBase64(); // Host public keys are obtained from the tunnel endpoint record published by the host. if (this.HostPublicKeys == null) { this.Trace.Warning( "Host identity could not be verified because no public keys were provided."); this.Trace.Verbose("Host key: " + hostKey); e.AuthenticationTask = Task.FromResult(new ClaimsPrincipal()); } else if (this.HostPublicKeys.Contains(hostKey)) { this.Trace.Verbose("Verified host identity with public key " + hostKey); e.AuthenticationTask = Task.FromResult(new ClaimsPrincipal()); } else if (Tunnel != null && ManagementClient != null) { this.Trace.Verbose("Host public key verification failed. Refreshing tunnel."); this.Trace.Verbose("Host key: " + hostKey); this.Trace.Verbose("Expected key(s): " + string.Join(", ", this.HostPublicKeys)); e.AuthenticationTask = RefreshTunnelAndAuthenticateHostAsync(hostKey, DisposeToken); } else { this.Trace.Error("Host public key verification failed."); this.Trace.Verbose("Host key: " + hostKey); this.Trace.Verbose("Expected key(s): " + string.Join(", ", this.HostPublicKeys)); } } private async Task RefreshTunnelAndAuthenticateHostAsync(string hostKey, CancellationToken cancellation) { var status = ConnectionStatus; ConnectionStatus = ConnectionStatus.RefreshingTunnelHostPublicKey; try { await RefreshTunnelAsync(cancellation); } finally { ConnectionStatus = status; } if (Tunnel == null) { this.Trace.Warning("Host public key verification failed. Tunnel is not found."); return null; } if (this.HostPublicKeys == null) { this.Trace.Warning( "Host identity could not be verified because no public keys were provided."); return new ClaimsPrincipal(); } if (this.HostPublicKeys.Contains(hostKey)) { this.Trace.Verbose("Verified host identity with public key " + hostKey); return new ClaimsPrincipal(); } this.Trace.Error("Host public key verification failed."); this.Trace.Verbose("Host key: " + hostKey); this.Trace.Verbose("Expected key(s): " + string.Join(", ", this.HostPublicKeys)); return null; } private void OnSshServerAuthenticating(object? sender, SshAuthenticatingEventArgs e) { if (this.ConnectionProtocol == TunnelRelayTunnelClient.WebSocketSubProtocol) { // For V1 protocol the SSH server is the host; it should be authenticated with public key. OnHostAuthenticating(sender, e); } else { // For V2 protocol the SSH server is the relay. // Relay server authentication is done via the websocket TLS host certificate. // If SSH encryption/authentication is used anyway, just accept any SSH host key. e.AuthenticationTask = Task.FromResult(new ClaimsPrincipal()); } } /// /// Ssh session has just been created but has not connected yet. /// This is a good place to set up event handlers and activate services on it. /// protected virtual void SshSessionCreated() { // All tunnel hosts and clients should disable this because they do not use it (for now) // and leaving it enabled is a potential security issue. SshPortForwardingService!.AcceptRemoteConnectionsForNonForwardedPorts = false; SshSession!.Closed += OnSshSessionClosed; IsSshSessionActive = true; } /// protected override async Task CloseSessionAsync(SshDisconnectReason disconnectReason, Exception? exception) { await base.CloseSessionAsync(disconnectReason, exception); if (SshSession != null && !SshSession.IsClosed) { if (exception != null) { await SshSession.CloseAsync(disconnectReason, exception); } else { await SshSession.CloseAsync(disconnectReason); } // Closing the SSH session does nothing if the session is in disconnected state, // which may happen for a reconnectable session when the connection drops. // Disposing of the session forces closing and frees up the resources. SshSession.Dispose(); } } /// /// SSH session has just closed. /// protected virtual void OnSshSessionClosed(Exception? exception) { IsSshSessionActive = false; SshSessionClosed?.Invoke(this, EventArgs.Empty); } private void OnRequest(object? sender, SshRequestEventArgs e) { if (e.Request.RequestType == "tcpip-forward" || e.Request.RequestType == "cancel-tcpip-forward") { // SshPortForwardingService.AcceptLocalConnectionsForForwardedPorts may be set to disable listening on local TCP ports e.IsAuthorized = true; } } /// public override async ValueTask DisposeAsync() { await base.DisposeAsync(); var session = this.SshSession; if (session != null) { await session.CloseAsync(SshDisconnectReason.ByApplication); } SshSessionClosed = null; } /// /// Opens a stream connected to a remote port for clients which cannot or do not want to forward local TCP ports. /// Returns null if the session gets closed, or the port is no longer forwarded by the host. /// /// /// Set to false before connecting the client to ensure /// that forwarded tunnel ports won't get local TCP listeners. /// /// Remote port to connect to. /// Cancellation token for the request. /// A representing the result of the asynchronous operation. /// If the tunnel is not yet connected and hasn't started connecting. public virtual async Task ConnectToForwardedPortAsync(int forwardedPort, CancellationToken cancellation) { if (!(SshPortForwardingService is PortForwardingService pfs)) { throw new InvalidOperationException("The client is not connected yet."); } try { return await pfs.ConnectToForwardedPortAsync(forwardedPort, cancellation); } catch (InvalidOperationException) { // The requested port is not forwarded now, though it used to be forwarded. // Assume the host has stopped forwarding on that port. } catch (SshChannelException) { // The streaming channel could not be opened, either because it was rejected by // the remote side, or the remote connection failed. } catch when (!IsSshSessionActive) { // The SSH session has closed while we tried to connect to the port. Assume the host has closed it. // If the SSH session is still active, and we got some unexpected exception, bubble it up. } return null; } /// public async Task RefreshPortsAsync(CancellationToken cancellation) { var session = this.SshSession; if (session == null || session.IsClosed) { throw new InvalidOperationException("Not connected."); } var request = new SessionRequestMessage { RequestType = TunnelHost.RefreshPortsRequestType, WantReply = true, }; await session.RequestAsync(request, cancellation); } private void OnSshSessionClosed(object? sender, SshSessionClosedEventArgs e) { if (sender is SshSession sshSession) { sshSession.Authenticating -= OnSshServerAuthenticating; sshSession.Disconnected -= OnSshSessionDisconnected; sshSession.Request -= OnRequest; sshSession.Closed -= OnSshSessionClosed; } // Clear the SSH session before setting the status to Disconnected, in case the // status-changed event handler immediately triggers annother connection attempt. this.SshSession = null; ConnectionStatus = ConnectionStatus.Disconnected; OnSshSessionClosed(e.Exception); if (e.Reason == SshDisconnectReason.ConnectionLost) { StartReconnectTaskIfNotDisposed(); } } } dev-tunnels-0.0.25/cs/src/Connections/TunnelConnection.cs000066400000000000000000000461001450757157500233550ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Collections.Generic; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using Microsoft.DevTunnels.Connections.Messages; using Microsoft.DevTunnels.Contracts; using Microsoft.DevTunnels.Management; using Microsoft.DevTunnels.Ssh; using Microsoft.DevTunnels.Ssh.Messages; using Microsoft.DevTunnels.Ssh.Tcp; namespace Microsoft.DevTunnels.Connections; /// /// Base class for tunnel client and host. /// public abstract class TunnelConnection : IAsyncDisposable, IPortForwardMessageFactory { private readonly CancellationTokenSource disposeCts = new(); private Task? reconnectTask; private ConnectionStatus connectionStatus; private TunnelConnectionOptions? connectionOptions; private Tunnel? tunnel; /// /// Creates a new instance of the class. /// public TunnelConnection(ITunnelManagementClient? managementClient, TraceSource trace) { ManagementClient = managementClient; Trace = Requires.NotNull(trace, nameof(trace)); } /// /// Connects to a tunnel. /// /// Tunnel to connect to. /// Cancellation token. /// The tunnel was not found. /// The client or host does not have /// access to connect to the tunnel. /// The client or host failed to connect to the /// tunnel, or connected but encountered a protocol error. public Task ConnectAsync(Tunnel tunnel, CancellationToken cancellation = default) => ConnectAsync(tunnel, options: null, cancellation); /// /// Connects to a tunnel. /// /// Tunnel to connect to. /// Options for the connection. /// Cancellation token. /// The tunnel was not found. /// The client or host does not have /// access to connect to the tunnel. /// The client or host failed to connect to the /// tunnel, or connected but encountered a protocol error. public abstract Task ConnectAsync( Tunnel tunnel, TunnelConnectionOptions? options, CancellationToken cancellation = default); /// /// Gets the connection status. /// public ConnectionStatus ConnectionStatus { get => this.connectionStatus; protected set { lock (DisposeLock) { if (this.disposeCts.IsCancellationRequested) { value = ConnectionStatus.Disconnected; } var previousConnectionStatus = this.connectionStatus; this.connectionStatus = value; if (value != previousConnectionStatus) { // If there were temporary connection issue, DisconnectException may be not null. // Since we have successfully connected after all, clean it up. if (value == ConnectionStatus.Connected) { DisconnectException = null; } OnConnectionStatusChanged(previousConnectionStatus, value); } } } } /// /// Get the last exception that caused disconnection. /// Null if not yet connected. /// If disconnection was caused by disposing of this object, the value may be either null, or the last exception when the connection failed. /// public Exception? DisconnectException { get; private set; } /// /// Get the tunnel that is being hosted or connected to. /// May be null if the tunnel client used relay service URL and tunnel access token directly. /// public Tunnel? Tunnel { get => this.tunnel; private set { if (value != this.tunnel) { this.tunnel = value; OnTunnelChanged(); } } } /// /// Trace to write output to console. /// protected TraceSource Trace { get; } /// /// Dispose token. /// protected CancellationToken DisposeToken => this.disposeCts.Token; /// /// Lock object that guards disposal. /// /// /// Locking on guarantees that won't get cancelled while the lock is held. /// protected object DisposeLock { get; } = new(); /// /// Management client used for connections. /// Not null for the tunnel host. Maybe null for the tunnel client. /// protected ITunnelManagementClient? ManagementClient { get; } /// /// Tunnel connector. /// protected ITunnelConnector? connector; /// /// Tunnel access token. /// protected string? accessToken; /// /// Determines whether E2E encryption is requested when opening connections through the tunnel /// (V2 protocol only). /// /// /// The default value is true, but applications may set this to false (for slightly faster /// connections). /// /// Note when this is true, E2E encryption is not strictly required. The tunnel relay and /// tunnel host can decide whether or not to enable E2E encryption for each connection, /// depending on policies and capabilities. Applications can verify the status of E2EE by /// handling the or /// event and checking the related property /// on the channel request or response message. /// public bool EnableE2EEncryption { get; set; } = true; /// /// Tunnel has been assigned to or changed. /// protected virtual void OnTunnelChanged() { this.accessToken = Tunnel?.TryGetAccessToken(TunnelAccessScope, out var token) == true ? token : null; } /// /// Validate if it is not null or empty. /// /// is thrown if the is expired. protected virtual void ValidateAccessToken() { if (!string.IsNullOrEmpty(this.accessToken)) { TunnelAccessTokenProperties.ValidateTokenExpiration(this.accessToken); } } /// /// Create tunnel connector for . /// protected abstract Task CreateTunnelConnectorAsync(CancellationToken cancellation); /// /// Get tunnel access scope for this tunnel client or host. /// protected abstract string TunnelAccessScope { get; } /// /// Event handler for refreshing the tunnel access token. /// The tunnel client or host fires this event when it is not able to use the access token it got from the tunnel. /// public event EventHandler? RefreshingTunnelAccessToken; /// /// Event raised when a tunnel connection attempt failed and is about to be retried. /// /// /// An event handler can cancel the retry by setting to false. /// public event EventHandler? RetryingTunnelConnection; /// /// Connection status changed event. /// /// /// Before any connection attempt is made, the connection status is /// . /// /// The status changes to when a connection attempt /// begins. /// /// The status changes to when a connection succeeds. /// /// When a connection attempt fails without ever connecting, and retries are not enabled /// ( is false) or an unrecoverable error was /// encountered, the status changes to . /// /// When a successful connection is lost, the status changes to either /// if reconnect is enabled /// ( is true or unspecified), otherwise /// . /// public event EventHandler? ConnectionStatusChanged; /// /// Assign the tunnel and connect to it. /// protected Task ConnectTunnelSessionAsync( Tunnel tunnel, TunnelConnectionOptions? options, CancellationToken cancellation) { Requires.NotNull(tunnel, nameof(tunnel)); this.connectionOptions = options; return ConnectTunnelSessionAsync(async (cancellation) => { var isReconnect = Tunnel != null; Tunnel = tunnel; this.connector ??= await CreateTunnelConnectorAsync(cancellation); await this.connector.ConnectSessionAsync(options, isReconnect, cancellation); }, cancellation); } /// /// Close tunnel session with the given reason and exception. /// /// /// This is used by when it couldn't connect the tunnel SSH session. /// Depending on whether the exception is recoverable, the tunnel connector may try to reconnect and start a new session, /// or give up and change to . /// protected virtual Task CloseSessionAsync(SshDisconnectReason disconnectReason, Exception? exception) { if (exception != null) { DisconnectException = exception; } return Task.CompletedTask; } /// /// Connect to tunnel session by running . /// protected async Task ConnectTunnelSessionAsync( Func connectAction, CancellationToken cancellation) { Requires.NotNull(connectAction, nameof(connectAction)); ConnectionStatus = ConnectionStatus.Connecting; var linkedCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellation, DisposeToken); cancellation = linkedCancellationSource.Token; try { await connectAction(cancellation); ConnectionStatus = ConnectionStatus.Connected; } catch (OperationCanceledException) { ConnectionStatus = ConnectionStatus.Disconnected; if (DisposeToken.IsCancellationRequested) { throw new ObjectDisposedException(nameof(TunnelConnection)); } throw; } catch (Exception ex) { Trace.Error( "Error connecting {0} tunnel session: {1}", TunnelAccessScope == TunnelAccessScopes.Connect ? "client" : "host", ex is UnauthorizedAccessException || ex is TunnelConnectionException ? ex.Message : ex); DisconnectException = ex; ConnectionStatus = ConnectionStatus.Disconnected; throw; } } /// /// Gets the fresh tunnel access token when Relay service returns 401. /// /// Thrown if the refreshed token is expired. protected async Task RefreshTunnelAccessTokenAsync(CancellationToken cancellation) { var previousStatus = ConnectionStatus; ConnectionStatus = ConnectionStatus.RefreshingTunnelAccessToken; Trace.Verbose( "Refreshing tunnel access token. Current token: {0}", TunnelAccessTokenProperties.GetTokenTrace(this.accessToken)); try { if (!await OnRefreshingTunnelAccessTokenAsync(cancellation)) { return false; } // Access token may be null if tunnel allows anonymous access. if (this.accessToken != null) { TunnelAccessTokenProperties.ValidateTokenExpiration(this.accessToken); } Trace.Verbose( "Refreshed tunnel access token. New token: {0}", TunnelAccessTokenProperties.GetTokenTrace(this.accessToken)); return true; } finally { ConnectionStatus = previousStatus; } } /// /// Fetch the tunnel from the service if and are not null. /// /// true if was refreshed; otherwise, false. protected virtual async Task RefreshTunnelAsync(CancellationToken cancellation) { if (Tunnel != null && ManagementClient != null) { Trace.TraceInformation("Refreshing tunnel."); var options = new TunnelRequestOptions { TokenScopes = new[] { TunnelAccessScope }, }; Tunnel = await ManagementClient.GetTunnelAsync(Tunnel, options, cancellation); if (Tunnel != null) { Trace.TraceInformation("Refreshed tunnel."); } else { Trace.TraceInformation("Tunnel not found."); } return true; } return false; } /// /// Refresh tunnel access token. /// /// /// If , are not null and is null, /// refreshes the tunnel with /// and this gets the token off it based on . /// Otherwise, invokes event. /// protected virtual async Task OnRefreshingTunnelAccessTokenAsync(CancellationToken cancellation) { var handler = RefreshingTunnelAccessToken; if (handler == null) { return await RefreshTunnelAsync(cancellation); } var eventArgs = new RefreshingTunnelAccessTokenEventArgs(TunnelAccessScope, cancellation); handler(this, eventArgs); if (eventArgs.TunnelAccessTokenTask == null) { return false; } this.accessToken = await eventArgs.TunnelAccessTokenTask.ConfigureAwait(false); return true; } /// public virtual async ValueTask DisposeAsync() { Task? reconnectTask; lock (DisposeLock) { this.disposeCts.Cancel(); reconnectTask = this.reconnectTask; } if (reconnectTask != null) { await reconnectTask; } ConnectionStatus = ConnectionStatus.Disconnected; } /// /// Event fired when the connection status has changed. /// protected virtual void OnConnectionStatusChanged(ConnectionStatus previousConnectionStatus, ConnectionStatus connectionStatus) { var handler = ConnectionStatusChanged; if (handler != null) { var args = new ConnectionStatusChangedEventArgs( previousConnectionStatus, connectionStatus, connectionStatus == ConnectionStatus.Disconnected ? DisconnectException : null); handler(this, args); } } /// /// Start reconnect task if the object is not yet disposed. /// protected void StartReconnectTaskIfNotDisposed() { lock (DisposeLock) { if (!this.disposeCts.IsCancellationRequested && this.connectionOptions?.EnableReconnect != false && this.reconnectTask == null && this.connector != null) // The connector may be null if the tunnel client/host was created directly from a stream. { var task = ReconnectAsync(this.disposeCts.Token); this.reconnectTask = !task.IsCompleted ? task : null; } else { ConnectionStatus = ConnectionStatus.Disconnected; } } } private async Task ReconnectAsync(CancellationToken cancellation) { Requires.NotNull(this.connector!, nameof(this.connector)); try { await ConnectTunnelSessionAsync( (cancellation) => this.connector.ConnectSessionAsync( this.connectionOptions, isReconnect: true, cancellation), cancellation); } catch { // Tracing of the exception has already been done by ConnectToTunnelSessionAsync. // As reconnection is an async process, there is nobody watching it throw. // The exception, if it was not cancellation, is stored in DisconnectException property. // There might have been ConnectionStatusChanged event fired as well. } lock (DisposeLock) { this.reconnectTask = null; } } /// /// Notifies about a connection retry, giving the application a chance to delay or cancel it. /// internal void OnRetrying(RetryingTunnelConnectionEventArgs e) { RetryingTunnelConnection?.Invoke(this, e); } Task IPortForwardMessageFactory.CreateRequestMessageAsync(int port) => Task.FromResult( new PortRelayRequestMessage { AccessToken = this.accessToken }); Task IPortForwardMessageFactory.CreateSuccessMessageAsync(int port) => Task.FromResult(new PortForwardSuccessMessage()); // Success messages are not extended. Task IPortForwardMessageFactory.CreateChannelOpenMessageAsync(int port) => Task.FromResult( new PortRelayConnectRequestMessage { AccessToken = this.accessToken, IsE2EEncryptionRequested = this.EnableE2EEncryption, }); } dev-tunnels-0.0.25/cs/src/Connections/TunnelConnectionException.cs000066400000000000000000000043571450757157500252440ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Net; namespace Microsoft.DevTunnels.Connections; /// /// Exception thrown when a host or client failed to connect to a tunnel. /// public class TunnelConnectionException : Exception { private const string HttpStatusCodeKey = "HttpStatusCode"; /// /// Creates a new instance of the class /// with a message and optional inner exception. /// /// Exception message. /// Optional inner exception. public TunnelConnectionException(string message, Exception? innerException = null) : base(message, innerException) { if (innerException != null) { var statusCode = GetHttpStatusCode(innerException); if (statusCode != default) { StatusCode = statusCode; } } } /// /// Creates a new instance of the class /// with a message, HTTP status code, and optional inner exception. /// /// Exception message. /// HTTP status code. /// Optional inner exception. public TunnelConnectionException( string message, HttpStatusCode statusCode, Exception? innerException = null) : this(message, innerException) { StatusCode = statusCode; } /// /// Gets the HTTP status code from the exception, or null if no status code is available. /// public HttpStatusCode? StatusCode { get; private set; } internal static HttpStatusCode GetHttpStatusCode(Exception ex) { var value = ex.Data[HttpStatusCodeKey]; return value is HttpStatusCode ? (HttpStatusCode)value : default; } internal static void SetHttpStatusCode(Exception ex, HttpStatusCode statusCode) { ex.Data[HttpStatusCodeKey] = statusCode; } }dev-tunnels-0.0.25/cs/src/Connections/TunnelConnectionOptions.cs000066400000000000000000000056151450757157500247370ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System.Threading; using Microsoft.DevTunnels.Contracts; namespace Microsoft.DevTunnels.Connections; /// /// Options for a tunnel host or client connection. /// /// public class TunnelConnectionOptions { /// /// Gets or sets a value indicating whether the connection will be automatically retried after /// a connection failure. /// /// /// The default value is true. When enabled, retries continue until the connection is /// successful, the cancellation token is cancelled, or an unrecoverable error is encountered. /// /// Recoverable errors include network connectivity issues, authentication issues (e.g. expired /// access token which may be refreshed before retrying), and service temporarily unavailable /// (HTTP 503). For rate-limiting errors (HTTP 429) only a limited number of retries are /// attempted before giving up. /// /// Retries are performed with exponential backoff, starting with a 100ms delay and doubling /// up to a maximum 12s delay, with further retries using the same max delay. /// /// Note after the initial connection succeeds, the host or client may still become disconnected /// at any time after that due to a network disruption or a relay service upgrade. When that /// happens, the option controls whether an automatic reconnect /// will be attempted. Reconnection has the same retry behavior. /// /// Listen to the event to be notified /// when the connection is retrying. /// public bool EnableRetry { get; set; } = true; /// /// Gets or sets a value indicating whether the connection will attempt to automatically /// reconnect (with no data loss) after a disconnection. /// /// /// The default value is true. /// /// If reconnection fails, or is not enabled, the application may still attempt to connect /// the client again, however in that case no state is preserved. /// /// Listen to the event to be notified /// when reconnection or disconnection occurs. /// public bool EnableReconnect { get; set; } = true; /// /// Gets or sets the ID of the tunnel host to connect to, if there are multiple /// hosts accepting connections on the tunnel, or null to connect to a single host /// (most common). This option applies only to client connections. /// public string? HostId { get; set; } } dev-tunnels-0.0.25/cs/src/Connections/TunnelHost.cs000066400000000000000000000150471450757157500222010ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Net; using System.Threading; using System.Threading.Tasks; using Microsoft.DevTunnels.Ssh; using Microsoft.DevTunnels.Ssh.Algorithms; using Microsoft.DevTunnels.Ssh.Tcp; using Microsoft.DevTunnels.Contracts; using Microsoft.DevTunnels.Management; using Microsoft.DevTunnels.Ssh.Tcp.Events; namespace Microsoft.DevTunnels.Connections; /// /// Base class for Hosts that host one tunnel /// public abstract class TunnelHost : TunnelConnection, ITunnelHost { internal const string RefreshPortsRequestType = "RefreshPorts"; private bool forwardConnectionsToLocalPorts = true; /// /// Sessions created between this host and clients. Lock on this hash set to be thread-safe. /// protected HashSet sshSessions = new(); /// public event EventHandler? ForwardedPortConnecting; /// /// Creates a new instance of the class. /// public TunnelHost(ITunnelManagementClient managementClient, TraceSource trace) : base(managementClient, trace) { } /// /// Enumeration of sessions created between this host and clients. Thread safe. /// protected IEnumerable SshSessions { get { lock (this.sshSessions) { return this.sshSessions.ToArray(); } } } /// public string? ConnectionProtocol { get; protected set; } /// /// Port Forwarders between host and clients /// /// /// This property is public for testing purposes, and may be removed in the future. /// public ConcurrentDictionary RemoteForwarders { get; } = new ConcurrentDictionary(); /// /// Private key used for connections. /// protected IKeyPair HostPrivateKey { get; } = SshAlgorithms.PublicKey.ECDsaSha2Nistp384.GenerateKeyPair(); /// protected override string TunnelAccessScope => TunnelAccessScopes.Host; /// public bool ForwardConnectionsToLocalPorts { get => this.forwardConnectionsToLocalPorts; set { if (value != this.forwardConnectionsToLocalPorts) { this.forwardConnectionsToLocalPorts = value; } } } /// [Obsolete("Use ConnectAsync() instead.")] public Task StartAsync(Tunnel tunnel, CancellationToken cancellation) => ConnectAsync(tunnel, cancellation); /// public override async Task ConnectAsync( Tunnel tunnel, TunnelConnectionOptions? options, CancellationToken cancellation = default) { Requires.NotNull(tunnel, nameof(tunnel)); Requires.NotNull(tunnel.Ports!, nameof(tunnel.Ports)); await ConnectTunnelSessionAsync(tunnel, options, cancellation); } internal async Task ForwardPortAsync( PortForwardingService pfs, TunnelPort port, CancellationToken cancellation) { var portNumber = (int)port.PortNumber; if (pfs.LocalForwardedPorts.Any((p) => p.LocalPort == portNumber)) { // The port is already forwarded. This may happen if we try to add the same port twice after reconnection. return; } // When forwarding from a Remote port we assume that the RemotePortNumber // and requested LocalPortNumber are the same. RemotePortForwarder? forwarder; try { forwarder = await pfs.ForwardFromRemotePortAsync( IPAddress.Loopback, portNumber, "localhost", portNumber, cancellation); } catch (SshConnectionException) { // Ignore exception caused by the session being closed; it will be reported elsewhere. // Treat it as equivalent to the client rejecting the forwarding request. forwarder = null; } catch (ObjectDisposedException) { forwarder = null; } if (forwarder == null) { // The forwarding request was rejected by the relay (V2) or client (V1). return; } // Capture the remote forwarder for the session id / remote port pair. // This is needed later to stop forwarding for this port when the remote forwarder is disposed. // Note when the client tries to open an SSH channel to PFS, the port forwarding service checks // its remoteConnectors whether the port is being forwarded. // Disposing of the RemotePortForwarder stops the forwarding and removes the remote connector // from PFS.remoteConnectors. // // Note the session ID may be null here in V2 protocol, when one (possibly unencrypted) // session is shared by all clients. RemoteForwarders.TryAdd( new SessionPortKey(pfs.Session.SessionId, (ushort)forwarder.LocalPort), forwarder); return; } /// public abstract Task RefreshPortsAsync(CancellationToken cancellation); /// /// Add client SSH session. Duplicates are ignored. /// Thread-safe. /// protected void AddClientSshSession(SshServerSession session) { Requires.NotNull(session, nameof(session)); lock (this.sshSessions) { this.sshSessions.Add(session); } } /// /// Invoked when a forwarded port is connecting /// protected virtual void OnForwardedPortConnecting( object? sender, ForwardedPortConnectingEventArgs e) { this.ForwardedPortConnecting?.Invoke(this, e); } /// /// Remove client SSH session. Noop if the session is not added. /// Thread-safe. /// protected void RemoveClientSshSession(SshServerSession session) { Requires.NotNull(session, nameof(session)); lock (this.sshSessions) { this.sshSessions.Remove(session); } } } dev-tunnels-0.0.25/cs/src/Connections/TunnelRelayStreamFactory.cs000066400000000000000000000027021450757157500250360ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.IO; using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; namespace Microsoft.DevTunnels.Connections { /// /// Default factory for creating streams to a tunnel relay. /// public class TunnelRelayStreamFactory : ITunnelRelayStreamFactory { /// public virtual async Task<(Stream Stream, string SubProtocol)> CreateRelayStreamAsync( Uri relayUri, string? accessToken, string[] subprotocols, CancellationToken cancellation) { void ConfigureWebSocketOptions(ClientWebSocketOptions options) { foreach (var subprotocol in subprotocols) { options.AddSubProtocol(subprotocol); } options.UseDefaultCredentials = false; if (!string.IsNullOrEmpty(accessToken)) { options.SetRequestHeader("Authorization", "tunnel " + accessToken); } } var stream = await WebSocketStream.ConnectToWebSocketAsync( relayUri, ConfigureWebSocketOptions, cancellation); return (stream, stream.SubProtocol); } } } dev-tunnels-0.0.25/cs/src/Connections/TunnelRelayTunnelClient.cs000066400000000000000000000172511450757157500246640ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.DevTunnels.Ssh; using Microsoft.DevTunnels.Contracts; using Microsoft.DevTunnels.Management; namespace Microsoft.DevTunnels.Connections; /// /// Tunnel client implementation that connects via a tunnel relay. /// public class TunnelRelayTunnelClient : TunnelClient, IRelayClient { /// /// Web socket sub-protocol to connect to the tunnel relay endpoint with v1 client protocol. /// public const string WebSocketSubProtocol = "tunnel-relay-client"; /// /// Web socket sub-protocol to connect to the tunnel relay endpoint with v2 client protocol. /// (The "-dev" suffix will be dropped when the v2 protocol is stable.) /// public const string WebSocketSubProtocolV2 = "tunnel-relay-client-v2-dev"; private Uri? relayUri; /// /// Creates a new instance of a client that connects to a tunnel via a tunnel relay. /// public TunnelRelayTunnelClient(TraceSource trace) : this(managementClient: null, trace) { } /// /// Creates a new instance of a client that connects to a tunnel via a tunnel relay. /// public TunnelRelayTunnelClient(ITunnelManagementClient? managementClient, TraceSource trace) : base(managementClient, trace) { } /// /// Gets or sets a factory for creating relay streams. /// /// /// Normally the default can be used. However a /// different factory class may be used to customize the connection (or mock the connection /// for testing). /// public ITunnelRelayStreamFactory StreamFactory { get; set; } = new TunnelRelayStreamFactory(); /// public override IReadOnlyCollection ConnectionModes => new[] { TunnelConnectionMode.TunnelRelay }; /// /// Tunnel has been assigned to or changed. /// Update tunnel access token, relay URI, and host public key from the tunnel. /// protected override void OnTunnelChanged() { base.OnTunnelChanged(); if (Tunnel?.Endpoints?.Length > 0) { var endpointGroups = Tunnel.Endpoints.GroupBy((ep) => ep.HostId).ToArray(); IGrouping endpoints; if (HostId != null) { endpoints = endpointGroups.SingleOrDefault((g) => g.Key == HostId) ?? throw new InvalidOperationException( "The specified host is not currently accepting connections to the tunnel."); } else if (endpointGroups.Length > 1) { throw new InvalidOperationException( "There are multiple hosts for the tunnel. Specify a host ID to connect to."); } else { endpoints = endpointGroups.Single(); } var endpoint = endpoints .OfType() .SingleOrDefault() ?? throw new InvalidOperationException( "The host is not currently accepting Tunnel relay connections."); Requires.Argument( !string.IsNullOrEmpty(endpoint?.ClientRelayUri), nameof(Tunnel), $"The tunnel client relay endpoint URI is missing."); this.relayUri = new Uri(endpoint.ClientRelayUri, UriKind.Absolute); this.HostPublicKeys = endpoint.HostPublicKeys; } else { this.relayUri = null; this.HostPublicKeys = null; } } /// protected override Task CreateTunnelConnectorAsync(CancellationToken cancellation) { Requires.NotNull(this.relayUri!, nameof(this.relayUri)); ITunnelConnector result = new RelayTunnelConnector(this); return Task.FromResult(result); } /// /// Connect to the clientRelayUri using accessToken. /// protected Task ConnectAsync( string clientRelayUri, string? accessToken, string[]? hostPublicKeys, TunnelConnectionOptions options, CancellationToken cancellation) { Requires.NotNull(clientRelayUri, nameof(clientRelayUri)); return ConnectTunnelSessionAsync( (cancellation) => { this.relayUri = new Uri(clientRelayUri, UriKind.Absolute); this.accessToken = accessToken; this.HostPublicKeys = hostPublicKeys; this.connector = new RelayTunnelConnector(this); return this.connector.ConnectSessionAsync( options, isReconnect: false, cancellation); }, cancellation); } /// /// Create stream to the tunnel. /// protected virtual async Task CreateSessionStreamAsync(CancellationToken cancellation) { var protocols = Environment.GetEnvironmentVariable("DEVTUNNELS_PROTOCOL_VERSION") switch { "1" => new[] { WebSocketSubProtocol }, "2" => new[] { WebSocketSubProtocolV2 }, // By default, prefer V2 and fall back to V1. _ => new[] { WebSocketSubProtocolV2, WebSocketSubProtocol }, }; ValidateAccessToken(); Trace.TraceInformation("Connecting to client tunnel relay {0}", this.relayUri!.AbsoluteUri); var (stream, subprotocol) = await this.StreamFactory.CreateRelayStreamAsync( this.relayUri!, this.accessToken, protocols, cancellation); Trace.TraceEvent(TraceEventType.Verbose, 0, "Connected with subprotocol '{0}'", subprotocol); ConnectionProtocol = subprotocol; return stream; } /// /// Configures tunnel SSH session with the given stream. /// protected virtual async Task ConfigureSessionAsync(Stream stream, bool isReconnect, CancellationToken cancellation) { var session = SshSession; if (isReconnect && session != null && !session.IsClosed) { await session.ReconnectAsync(stream, cancellation); } else { await StartSshSessionAsync(stream, cancellation); } } #region IRelayClient /// string IRelayClient.TunnelAccessScope => TunnelAccessScope; /// TraceSource IRelayClient.Trace => Trace; /// Task IRelayClient.CreateSessionStreamAsync(CancellationToken cancellation) => CreateSessionStreamAsync(cancellation); /// Task IRelayClient.CloseSessionAsync(SshDisconnectReason disconnectReason, Exception? exception) => CloseSessionAsync(disconnectReason, exception); /// Task IRelayClient.ConfigureSessionAsync(Stream stream, bool isReconnect, CancellationToken cancellation) => ConfigureSessionAsync(stream, isReconnect, cancellation); /// Task IRelayClient.RefreshTunnelAccessTokenAsync(CancellationToken cancellation) => RefreshTunnelAccessTokenAsync(cancellation); /// void IRelayClient.OnRetrying(RetryingTunnelConnectionEventArgs e) => OnRetrying(e); #endregion IRelayClient } dev-tunnels-0.0.25/cs/src/Connections/TunnelRelayTunnelHost.cs000066400000000000000000000710511450757157500243610ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Security.Claims; using System.Threading; using System.Threading.Tasks; using Microsoft.DevTunnels.Connections.Messages; using Microsoft.DevTunnels.Contracts; using Microsoft.DevTunnels.Management; using Microsoft.DevTunnels.Ssh; using Microsoft.DevTunnels.Ssh.Events; using Microsoft.DevTunnels.Ssh.Messages; using Microsoft.DevTunnels.Ssh.Tcp; using Microsoft.DevTunnels.Ssh.Tcp.Events; namespace Microsoft.DevTunnels.Connections; /// /// Tunnel host implementation that uses data-plane relay /// to accept client connections. /// public class TunnelRelayTunnelHost : TunnelHost, IRelayClient { /// /// Web socket sub-protocol to connect to the tunnel relay endpoint with v1 host protocol. /// public const string WebSocketSubProtocol = "tunnel-relay-host"; /// /// Web socket sub-protocol to connect to the tunnel relay endpoint with v2 host protocol. /// (The "-dev" suffix will be dropped when the v2 protocol is stable.) /// public const string WebSocketSubProtocolV2 = "tunnel-relay-host-v2-dev"; /// /// Ssh channel type in host relay ssh session where client session streams are passed. /// public const string ClientStreamChannelType = "client-ssh-session-stream"; private readonly IList clientSessionTasks = new List(); private readonly string hostId; private readonly ICollection reconnectableSessions = new List(); private SshClientSession? hostSession; private Uri? relayUri; /// /// Creates a new instance of a host that connects to a tunnel via a tunnel relay. /// public TunnelRelayTunnelHost(ITunnelManagementClient managementClient, TraceSource trace) : base(managementClient, trace) { this.hostId = MultiModeTunnelHost.HostId; } /// /// Gets or sets a factory for creating relay streams. /// /// /// Normally the default can be used. However a /// different factory class may be used to customize the connection (or mock the connection /// for testing). /// public ITunnelRelayStreamFactory StreamFactory { get; set; } = new TunnelRelayStreamFactory(); /// public override async ValueTask DisposeAsync() { await base.DisposeAsync(); var hostSession = this.hostSession; if (hostSession != null) { this.hostSession = null; await hostSession.CloseAsync(SshDisconnectReason.None); hostSession.Dispose(); } List tasks; lock (DisposeLock) { tasks = new List(this.clientSessionTasks); this.clientSessionTasks.Clear(); } if (Tunnel != null) { tasks.Add(ManagementClient!.DeleteTunnelEndpointsAsync(Tunnel, this.hostId, TunnelConnectionMode.TunnelRelay)); } foreach (RemotePortForwarder forwarder in RemoteForwarders.Values) { forwarder.Dispose(); } await Task.WhenAll(tasks); } /// protected override async Task CreateTunnelConnectorAsync(CancellationToken cancellation) { if (this.hostSession != null) { throw new InvalidOperationException( "Already connected. Use separate instances to connect to multiple tunnels."); } Requires.NotNull(Tunnel!, nameof(Tunnel)); Requires.Argument(this.accessToken != null, nameof(Tunnel), $"There is no access token for {TunnelAccessScope} scope on the tunnel."); var hostPublicKeys = new[] { HostPrivateKey.GetPublicKeyBytes(HostPrivateKey.KeyAlgorithmName).ToBase64(), }; var endpoint = new TunnelRelayTunnelEndpoint { HostId = this.hostId, HostPublicKeys = hostPublicKeys, }; List>? additionalQueryParams = null; if (Tunnel.Ports != null && Tunnel.Ports.Any((p) => p.Protocol == TunnelProtocol.Ssh)) { additionalQueryParams = new () {new KeyValuePair("includeSshGatewayPublicKey", "true")}; } endpoint = (TunnelRelayTunnelEndpoint)await ManagementClient!.UpdateTunnelEndpointAsync( Tunnel, endpoint, options: new TunnelRequestOptions() { AdditionalQueryParameters = additionalQueryParams, }, cancellation); Requires.Argument( !string.IsNullOrEmpty(endpoint?.HostRelayUri), nameof(Tunnel), $"The tunnel host relay endpoint URI is missing."); this.relayUri = new Uri(endpoint.HostRelayUri, UriKind.Absolute); return new RelayTunnelConnector(this); } /// /// Create stream to the tunnel. /// protected virtual async Task CreateSessionStreamAsync(CancellationToken cancellation) { var protocols = Environment.GetEnvironmentVariable("DEVTUNNELS_PROTOCOL_VERSION") switch { "1" => new[] { WebSocketSubProtocol }, "2" => new[] { WebSocketSubProtocolV2 }, // By default, prefer V2 and fall back to V1. _ => new[] { WebSocketSubProtocolV2, WebSocketSubProtocol }, }; ValidateAccessToken(); Trace.Verbose("Connecting to host tunnel relay {0}", this.relayUri!.AbsoluteUri); var (stream, subprotocol) = await this.StreamFactory.CreateRelayStreamAsync( this.relayUri!, this.accessToken, protocols, cancellation); Trace.TraceEvent(TraceEventType.Verbose, 0, "Connected with subprotocol '{0}'", subprotocol); ConnectionProtocol = subprotocol; return stream; } /// protected override async Task CloseSessionAsync(SshDisconnectReason disconnectReason, Exception? exception) { await base.CloseSessionAsync(disconnectReason, exception); var hostSession = this.hostSession; if (hostSession != null) { await hostSession.CloseAsync(disconnectReason); hostSession.Dispose(); } } #region IRelayClient /// string IRelayClient.TunnelAccessScope => TunnelAccessScope; /// TraceSource IRelayClient.Trace => Trace; /// Task IRelayClient.CreateSessionStreamAsync(CancellationToken cancellation) => CreateSessionStreamAsync(cancellation); /// async Task IRelayClient.ConfigureSessionAsync(Stream stream, bool isReconnect, CancellationToken cancellation) { SshClientSession session; if (ConnectionProtocol == WebSocketSubProtocol) { // The V1 protocol always configures no security, equivalent to SSH MultiChannelStream. // The websocket transport is still encrypted and authenticated. session = new SshClientSession( SshSessionConfiguration.NoSecurity, Trace.WithName("HostSSH")); } else { // The V2 protocol configures optional encryption, including "none" as an enabled and // preferred key-exchange algorithm, because encryption of the outer SSH session is // optional since it is already over a TLS websocket. var config = new SshSessionConfiguration(); config.KeyExchangeAlgorithms.Clear(); config.KeyExchangeAlgorithms.Add(SshAlgorithms.KeyExchange.None); config.KeyExchangeAlgorithms.Add(SshAlgorithms.KeyExchange.EcdhNistp384); config.KeyExchangeAlgorithms.Add(SshAlgorithms.KeyExchange.EcdhNistp256); config.KeyExchangeAlgorithms.Add(SshAlgorithms.KeyExchange.DHGroup16Sha512); config.KeyExchangeAlgorithms.Add(SshAlgorithms.KeyExchange.DHGroup14Sha256); config.AddService(typeof(PortForwardingService)); session = new SshClientSession(config, Trace.WithName("HostSSH")); // Relay server authentication is done via the websocket TLS host certificate. // If SSH encryption/authentication is used anyway, just accept any SSH host key. session.Authenticating += (_, e) => e.AuthenticationTask = Task.FromResult(new ClaimsPrincipal()); var hostPfs = session.ActivateService(); hostPfs.MessageFactory = this; hostPfs.ForwardedPortConnecting += OnForwardedPortConnecting; } this.hostSession = session; session.ChannelOpening += OnHostSessionChannelOpening; session.Closed += OnHostSessionClosed; await session.ConnectAsync(stream, cancellation); // SSH authentication is skipped in V1 protocol, optional in V2 depending on whether the // session performed a key exchange (as indicated by having a session ID or not). In the // latter case a password is not required. Strong authentication was already handled by // the relay service via the tunnel access token used for the websocket connection. if (session.SessionId != null) { var clientCredentials = new SshClientCredentials("tunnel", password: null); await session.AuthenticateAsync(clientCredentials); } if (ConnectionProtocol == WebSocketSubProtocolV2) { // In the v2 protocol, the host starts "forwarding" the ports as soon as it connects. // Then the relay will forward the forwarded ports to clients as they connect. await StartForwardingExistingPortsAsync(session); } } /// Task IRelayClient.CloseSessionAsync(SshDisconnectReason disconnectReason, Exception? exception) => CloseSessionAsync(disconnectReason, exception); /// Task IRelayClient.RefreshTunnelAccessTokenAsync(CancellationToken cancellation) => RefreshTunnelAccessTokenAsync(cancellation); /// void IRelayClient.OnRetrying(RetryingTunnelConnectionEventArgs e) => OnRetrying(e); #endregion IRelayClient private void OnHostSessionClosed(object? sender, SshSessionClosedEventArgs e) { var session = (SshClientSession)sender!; session.Closed -= OnHostSessionClosed; session.ChannelOpening -= OnHostSessionChannelOpening; this.hostSession = null; Trace.TraceInformation( "Connection to host tunnel relay closed.{0}", DisposeToken.IsCancellationRequested ? string.Empty : " Reconnecting."); if (e.Reason == SshDisconnectReason.ConnectionLost) { StartReconnectTaskIfNotDisposed(); } } private void OnHostSessionChannelOpening(object? sender, SshChannelOpeningEventArgs e) { if (!e.IsRemoteRequest) { // Auto approve all local requests (not that there are any for the time being). return; } if (ConnectionProtocol == WebSocketSubProtocolV2 && e.Channel.ChannelType == "forwarded-tcpip") { // With V2 protocol, the relay server always sends an extended channel open message // with a property indicating whether E2E encryption is requested for the connection. // The host returns an extended response message indicating if E2EE is enabled. var relayRequestMessage = e.Channel.OpenMessage .ConvertTo(); var responseMessage = new PortRelayConnectResponseMessage(); // The host can enable encryption for the channel if the client requested it. responseMessage.IsE2EEncryptionEnabled = this.EnableE2EEncryption && relayRequestMessage.IsE2EEncryptionRequested; // In the future the relay might send additional information in the connect // request message, for example a user identifier that would enable the host to // group channels by user. e.OpeningTask = Task.FromResult(responseMessage); return; } else if (e.Channel.ChannelType != ClientStreamChannelType) { e.FailureDescription = $"Unknown channel type: {e.Channel.ChannelType}."; e.FailureReason = SshChannelOpenFailureReason.UnknownChannelType; return; } // V1 protocol. // Increase max window size to work around channel congestion bug. // This does not entirely eliminate the problem, but reduces the chance. e.Channel.MaxWindowSize = SshChannel.DefaultMaxWindowSize * 5; Task task; lock (DisposeLock) { if (DisposeToken.IsCancellationRequested) { e.FailureDescription = $"The host is disconnecting."; e.FailureReason = SshChannelOpenFailureReason.ConnectFailed; return; } task = AcceptClientSessionAsync(e.Channel, DisposeToken); this.clientSessionTasks.Add(task); } task.ContinueWith(RemoveClientSessionTask); void RemoveClientSessionTask(Task t) { lock (DisposeLock) { this.clientSessionTasks.Remove(t); } } } /// /// Encrypts the channel if necessary when a port connection is established (v2 only). /// protected override void OnForwardedPortConnecting( object? sender, ForwardedPortConnectingEventArgs e) { var channel = e.Stream.Channel; var relayRequestMessage = channel.OpenMessage .ConvertTo(); bool isE2EEncryptionEnabled = this.EnableE2EEncryption && relayRequestMessage.IsE2EEncryptionRequested; if (isE2EEncryptionEnabled) { // Increase the max window size so that it is at least larger than the window // size of one client channel. channel.MaxWindowSize = SshChannel.DefaultMaxWindowSize * 2; SshServerCredentials serverCredentials = new SshServerCredentials(HostPrivateKey); var secureStream = new SecureStream( e.Stream, serverCredentials, this.reconnectableSessions, channel.Trace.WithName(channel.Trace.Name + "." + channel.ChannelId)); // The client was already authenticated by the relay. secureStream.Authenticating += (_, e) => e.AuthenticationTask = Task.FromResult( new ClaimsPrincipal()); e.TransformTask = Task.FromResult(secureStream); // The client will connect to the secure stream after the channel is opened. ConnectEncryptedChannel(); async void ConnectEncryptedChannel() { try { // Do not pass the cancellation token from the connecting event, // because the connection will outlive the event. await secureStream.ConnectAsync(); } catch (Exception ex) { // Catch all exceptions in this async void method. try { channel.Trace.Error("Error connecting encrypted channel: " + ex.Message); } catch (Exception) { } } } } base.OnForwardedPortConnecting(sender, e); } private async Task AcceptClientSessionAsync(SshChannel clientSessionChannel, CancellationToken cancellation) { try { var stream = new SshStream(clientSessionChannel); await ConnectAndRunClientSessionAsync(stream, cancellation); } catch (OperationCanceledException) { } catch (Exception exception) { Trace.TraceEvent(TraceEventType.Error, 0, "Error running client SSH session: {0}", exception.Message); } } private async Task ConnectAndRunClientSessionAsync(Stream stream, CancellationToken cancellation) { var sshSessionOwnsStream = false; var tcs = new TaskCompletionSource(); try { // Always enable reconnect on client SSH server. // When a client reconnects, relay service just opens another SSH channel of client-ssh-session-stream type for it. var serverConfig = new SshSessionConfiguration(enableReconnect: true); // Enable port-forwarding via the SSH protocol. serverConfig.AddService(typeof(PortForwardingService)); var session = new SshServerSession(serverConfig, this.reconnectableSessions, Trace.WithName("ClientSSH")); session.Credentials = new SshServerCredentials(this.HostPrivateKey); using var tokenRegistration = cancellation.CanBeCanceled ? cancellation.Register(() => tcs.TrySetCanceled(cancellation)) : default; session.Authenticating += OnSshClientAuthenticating; session.ClientAuthenticated += OnSshClientAuthenticated; session.Reconnected += OnSshClientReconnected; session.Request += OnClientSessionRequest; session.ChannelOpening += OnSshChannelOpening; session.Closed += OnClientSessionClosed; try { var portForwardingService = session.ActivateService(); // All tunnel hosts and clients should disable this because they do not use it (for now) and leaving it enabled is a potential security issue. portForwardingService.AcceptRemoteConnectionsForNonForwardedPorts = false; await session.ConnectAsync(stream, cancellation); sshSessionOwnsStream = true; AddClientSshSession(session); await tcs.Task; } finally { if (!session.IsClosed) { await session.CloseAsync(SshDisconnectReason.ByApplication); } session.Authenticating -= OnSshClientAuthenticating; session.ClientAuthenticated -= OnSshClientAuthenticated; session.Reconnected -= OnSshClientReconnected; session.ChannelOpening -= OnSshChannelOpening; session.Closed -= OnClientSessionClosed; RemoveClientSshSession(session); } } catch when (!sshSessionOwnsStream) { stream.Close(); throw; } void OnClientSessionClosed(object? sender, SshSessionClosedEventArgs e) { TraceSource trace = ((SshSession)sender!).Trace; // Reconnecting client session may cause the new session to close with 'None' reason. if (cancellation.IsCancellationRequested) { trace.Verbose("Session cancelled."); } else if (e.Reason == SshDisconnectReason.ByApplication) { trace.Verbose("Session closed."); } else if (e.Reason != SshDisconnectReason.None) { trace.TraceEvent( TraceEventType.Error, 0, "Session closed unexpectedly due to {0}, \"{1}\"\n{2}", e.Reason, e.Message, e.Exception); } tcs.TrySetResult(null); } } private void OnClientSessionRequest( object? sender, SshRequestEventArgs e) { if (e.RequestType == RefreshPortsRequestType) { e.ResponseTask = Task.Run(async () => { // This may send tcpip-forward or cancel-tcpip-forward requests to clients. // Forward requests (but not cancellations) wait for client responses. await RefreshPortsAsync(e.Cancellation); return new SessionRequestSuccessMessage(); }); } } private void OnSshClientAuthenticating(object? sender, SshAuthenticatingEventArgs e) { if (e.AuthenticationType == SshAuthenticationType.ClientNone) { // For now, the client is allowed to skip SSH authentication; // they must have a valid tunnel access token already to get this far. e.AuthenticationTask = Task.FromResult(new ClaimsPrincipal()); } else { // Other authentication types are not implemented. Doing nothing here // results in a client authentication failure. } } private async void OnSshClientAuthenticated(object? sender, EventArgs e) => await StartForwardingExistingPortsAsync((SshServerSession)sender!); private async void OnSshClientReconnected(object? sender, EventArgs e) => await StartForwardingExistingPortsAsync((SshServerSession)sender!, removeUnusedPorts: true); private async Task StartForwardingExistingPortsAsync( SshSession session, bool removeUnusedPorts = false) { try { // Send port-forward request messages concurrently. The client may still handle the // requests sequentially but at least there is no network round-trip between them. var forwardTasks = new List(); var tunnelPorts = Tunnel!.Ports ?? Enumerable.Empty(); var pfs = session.ActivateService(); pfs.ForwardConnectionsToLocalPorts = this.ForwardConnectionsToLocalPorts; foreach (TunnelPort port in tunnelPorts) { // ForwardPortAsync() catches and logs most exceptions that might normally occur. forwardTasks.Add(ForwardPortAsync(pfs, port, CancellationToken.None)); } await Task.WhenAll(forwardTasks); // If a tunnel client reconnects, its SSH session Port Forwarding service may // have remote port forwarders for the ports no longer forwarded. // Remove such forwarders. if (removeUnusedPorts && session.SessionId != null) { tunnelPorts = Tunnel!.Ports ?? Enumerable.Empty(); var unusedLocalPorts = new HashSet( pfs.LocalForwardedPorts .Select(p => p.LocalPort) .Where(localPort => localPort.HasValue && !tunnelPorts.Any(tp => tp.PortNumber == localPort)) .Select(localPort => localPort!.Value)); var remoteForwardersToDispose = RemoteForwarders .Where((kvp) => ((kvp.Key.SessionId == null && session.SessionId == null) || ((kvp.Key.SessionId != null && session.SessionId != null) && Enumerable.SequenceEqual(kvp.Key.SessionId, session.SessionId))) && unusedLocalPorts.Contains(kvp.Value.LocalPort)) .Select(kvp => kvp.Key); foreach (SessionPortKey key in remoteForwardersToDispose) { if (RemoteForwarders.TryRemove(key, out var remoteForwarder)) { remoteForwarder?.Dispose(); } } } } catch (Exception ex) { // Catch unexpected exceptions because this method is called from async void methods. TraceSource trace = session.Trace; trace.TraceEvent( TraceEventType.Error, 0, "Unhandled exception when forwarding ports.\n{1}", ex); } } private void OnSshChannelOpening(object? sender, SshChannelOpeningEventArgs e) { if (e.Request is not PortForwardChannelOpenMessage portForwardRequest) { if (e.Request is ChannelOpenMessage channelOpenMessage) { // This allows the Go SDK to open an unused terminal channel if (channelOpenMessage.ChannelType == SshChannel.SessionChannelType) { return; } } Trace.Warning("Rejecting request to open non-portforwarding channel."); e.FailureReason = SshChannelOpenFailureReason.AdministrativelyProhibited; return; } if (portForwardRequest.ChannelType == "direct-tcpip") { if (!Tunnel!.Ports!.Any((p) => p.PortNumber == portForwardRequest.Port)) { Trace.Warning("Rejecting request to connect to non-forwarded port:" + portForwardRequest.Port); e.FailureReason = SshChannelOpenFailureReason.AdministrativelyProhibited; } } else if (portForwardRequest.ChannelType == "forwarded-tcpip") { var eventArgs = new ForwardedPortConnectingEventArgs( (int)portForwardRequest.Port, false, new SshStream(e.Channel), CancellationToken.None); base.OnForwardedPortConnecting(this, eventArgs); } // For forwarded-tcpip do not check RemoteForwarders because they may not be updated yet. // There is a small time interval in ForwardPortAsync() between the port // being forwarded with ForwardFromRemotePortAsync() and RemoteForwarders updated. // Setting PFS.AcceptRemoteConnectionsForNonForwardedPorts to false makes PFS reject forwarding requests from the // clients for the ports that are not forwarded and are missing in PFS.remoteConnectors. // Call to PFS.ForwardFromRemotePortAsync() in ForwardPortAsync() adds the connector to PFS.remoteConnectors. else { Trace.Warning("Unrecognized channel type " + portForwardRequest.ChannelType); e.FailureReason = SshChannelOpenFailureReason.UnknownChannelType; } } /// public override async Task RefreshPortsAsync(CancellationToken cancellation) { if (Tunnel == null || ManagementClient == null) { return; } var updatedTunnel = await ManagementClient.GetTunnelAsync( Tunnel, new TunnelRequestOptions { IncludePorts = true }); var updatedPorts = updatedTunnel?.Ports ?? Array.Empty(); Tunnel.Ports = updatedPorts; var forwardTasks = new List(); var sessions = SshSessions.Cast(); if (ConnectionProtocol == WebSocketSubProtocolV2) { // In the V2 protocol, ports are forwarded direclty on the host session. // (But even when the host is V2, some clients may still connect with V1.) sessions = sessions.Append(this.hostSession); } foreach (var port in updatedPorts) { foreach (var session in sessions.Where((s) => s?.IsConnected == true)) { var key = new SessionPortKey(session!.SessionId!, port.PortNumber); if (!RemoteForwarders.ContainsKey(key)) { // Overlapping refresh operations could cause duplicate forward requests to be // sent to clients, but clients should ignore the duplicate requests. var pfs = session.GetService()!; forwardTasks.Add(ForwardPortAsync(pfs, port, cancellation)); } } } foreach (var forwarder in RemoteForwarders) { if (!updatedPorts.Any((p) => p.PortNumber == forwarder.Value.RemotePort)) { // Since RemoteForwarders is a concurrent dictionary, overlapping refresh // operations will only be able to remove and dispose a forwarder once. if (RemoteForwarders.TryRemove(forwarder.Key, out _)) { forwarder.Value.Dispose(); } } } await Task.WhenAll(forwardTasks); } } dev-tunnels-0.0.25/cs/src/Connections/WebSocketStream.cs000066400000000000000000000502221450757157500231320ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Diagnostics; using System.Globalization; using System.IO; using System.Net; using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; namespace Microsoft.DevTunnels.Connections { /// /// Wraps a to adapt it to . /// /// /// Cancelling reads or writes will abort the web socket connection. /// If backs SshSession when it is closed, SshSession cancels the read from the stream, which will abort the web socket. /// public class WebSocketStream : Stream { /// /// Maximum length of close description. Everything longer will be truncted. /// public const int CloseDescriptionMaxLength = 123; private const int LastWriteTimeoutBeforeCloseMs = 15_000; private const int CloseTimeoutMs = 15_000; private readonly object lockObject = new object(); private readonly CancellationTokenSource writeCts = new CancellationTokenSource(); private readonly TaskCompletionSource disposeCompletion = new TaskCompletionSource(); // Thread-safety: // - It's acceptable to call ReceiveAsync and SendAsync in parallel. One of each may run concurrently. // - It's acceptable to have a pending ReceiveAsync while CloseOutputAsync or CloseAsync is called. // - Attempting to invoke any other operations in parallel may corrupt the instance. Attempting to invoke // a send operation while another is in progress or a receive operation while another is in progress will // result in an exception. private readonly WebSocket socket; private WebSocketCloseStatus? closeStatus; private string? closeStatusDescription; private Task? lastWriteTask; private bool isDisposed; /// /// Creates a new instance of wrapping connected . /// If the is not in it will be closed. /// /// Web socket to wrap. /// If is null. /// If has not connected yet (i.e in state). public WebSocketStream(WebSocket socket) { this.socket = Requires.NotNull(socket, nameof(socket)); Requires.Argument(socket.State != WebSocketState.Connecting, nameof(socket), "The web socket has not connected yet."); if (socket.State != WebSocketState.Open) { Close(); } } /// /// Gets the websocket sub-protocol. /// public string SubProtocol => this.socket.SubProtocol!; /// /// Current web socket close status or null if not closed. /// public WebSocketCloseStatus? CloseStatus { get => this.socket.CloseStatus; set => this.closeStatus = value; } /// /// Current web socket close status description.. /// public string? CloseStatusDescription { get => this.socket.CloseStatusDescription; set => this.closeStatusDescription = value; } /// /// Connect to web socket. /// public static async Task ConnectToWebSocketAsync(Uri uri, Action? configure = default, CancellationToken cancellation = default) { var socket = new ClientWebSocket(); try { configure?.Invoke(socket.Options); await socket.ConnectAsync(uri, cancellation); return new WebSocketStream(socket); } catch (WebSocketException wse) when (wse.WebSocketErrorCode == WebSocketError.NotAWebSocket) { // The http request didn't upgrade to a web socket and instead may have returned a status code. // As a workaround, check for "'{actual response code}'" pattern in the exception message, // which may look like this: "The server returned status code '403' when status code '101' was expected". // TODO: switch to ClientWebSocket.HttpStatusCode when we get to .NET CORE 7.0, see https://github.com/dotnet/runtime/issues/25918#issuecomment-1132039238 int i = wse.Message.IndexOf('\''); if (i >= 0) { int j = wse.Message.IndexOf('\'', i + 1); if (j > i + 1 && int.TryParse( wse.Message.Substring(i + 1, j - i - 1), NumberStyles.None, CultureInfo.InvariantCulture, out var statusCode) && statusCode != 101) { TunnelConnectionException.SetHttpStatusCode(wse, (HttpStatusCode)statusCode); socket.Dispose(); throw wse; } } socket.Dispose(); throw; } catch { socket.Dispose(); throw; } } /// /// Close stream and the web socket with and . /// /// /// If the socket is already closed, this is no-op, and do not change. /// public ValueTask CloseAsync(WebSocketCloseStatus closeStatus, string? closeStatusDescription = default) { this.closeStatus = closeStatus; this.closeStatusDescription = closeStatusDescription; return DisposeAsync(); } /// protected override void Dispose(bool disposing) { if (disposing && !this.isDisposed) { DisposeAsync().AsTask().Wait(); } base.Dispose(disposing); } /// public override async ValueTask DisposeAsync() { Task? lastWriteTask = null; bool disposing = false; if (!this.isDisposed) { lock (this.lockObject) { disposing = !this.isDisposed; this.isDisposed = true; lastWriteTask = this.lastWriteTask; } } if (!disposing) { await this.disposeCompletion.Task; return; } // We cannot write in parallel with closing the socket, so we need to wait for the last write task. // We will give it some timeout if there is no debugger attached. if (lastWriteTask != null) { if (!Debugger.IsAttached) { this.writeCts.CancelAfter(LastWriteTimeoutBeforeCloseMs); } try { await lastWriteTask.ConfigureAwait(false); } catch { // Ignore exceptions here. The caller of the last write will observer them. } } this.writeCts.Dispose(); if (this.socket.State != WebSocketState.Closed && this.socket.State != WebSocketState.Aborted) { using CancellationTokenSource cts = new CancellationTokenSource(); try { // The socket must be in one of the following states now. It's OK to call CloseAsync on it. // WebSocketState.Open, WebSocketState.CloseReceived, WebSocketState.CloseSent var closeStatus = this.closeStatus ?? WebSocketCloseStatus.NormalClosure; string? closeStatusDescription = this.closeStatusDescription == null ? null : this.closeStatusDescription.Length <= CloseDescriptionMaxLength ? this.closeStatusDescription : this.closeStatusDescription.Substring(0, CloseDescriptionMaxLength); if (!Debugger.IsAttached) { cts.CancelAfter(CloseTimeoutMs); } await this.socket.CloseAsync(closeStatus, closeStatusDescription, cts.Token).ConfigureAwait(false); } catch (OperationCanceledException) { // Timed out waiting for close handshake. } catch (ObjectDisposedException) { // Already disposed. } catch (WebSocketException wse) when (wse.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely) { // Other party closed connection prematurely. } catch (Exception) { // TODO: log the exception. // Do not throw from DisposeAsync. } } this.socket.Dispose(); await base.DisposeAsync(); this.disposeCompletion.TrySetResult(null); } /// public override bool CanRead => !this.isDisposed; /// public override bool CanSeek => false; /// public override bool CanWrite => !this.isDisposed; /// public override bool CanTimeout => false; /// public override long Length => throw new InvalidOperationException(); /// public override long Position { get => throw new InvalidOperationException(); set => throw new InvalidOperationException(); } /// public override void Flush() { ThrowIfDisposed(); } /// public override Task FlushAsync(CancellationToken cancellationToken) { ThrowIfDisposed(); return Task.CompletedTask; } /// public override int Read(byte[] buffer, int offset, int count) => ReadAsync(buffer, offset, count).GetAwaiter().GetResult(); /// public override long Seek(long offset, SeekOrigin origin) => throw new InvalidOperationException(); /// public override void SetLength(long value) => throw new InvalidOperationException(); /// public override void Write(byte[] buffer, int offset, int count) => WriteAsync(buffer, offset, count).GetAwaiter().GetResult(); /// public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { Requires.NotNull(buffer, nameof(buffer)); Requires.Range(offset >= 0 && offset < buffer.Length, nameof(offset)); Requires.Range(count >= 0 && count <= buffer.Length - offset, nameof(count)); if (!CanReadFromSocket) { return 0; } try { Task task; lock (this.lockObject) { if (!CanReadFromSocket) { return 0; } task = this.socket.ReceiveAsync(new ArraySegment(buffer, offset, count), cancellationToken); } var result = await task.ConfigureAwait(false); if (result.MessageType == WebSocketMessageType.Close) { // Other party is closing the socket. Close(); return 0; } return result.Count; } catch (OperationCanceledException) { // Cancellation requested or socket was closed. // If the socket was closed, treat this as the end of the stream. if (!CanReadFromSocket) { return 0; } throw; } catch (WebSocketException wse) when (wse.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely) { // Other party closed connection prematurely. Close(); return 0; } } /// public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { if (!CanReadFromSocket) { return 0; } try { ValueTask task; lock (this.lockObject) { if (!CanReadFromSocket) { return 0; } task = this.socket.ReceiveAsync(buffer, cancellationToken); } var result = await task.ConfigureAwait(false); if (result.MessageType == WebSocketMessageType.Close) { // Other party is closing the socket. Close(); return 0; } return result.Count; } catch (OperationCanceledException) { // Cancellation requested or socket was closed. // If the socket was closed, treat this as the end of the stream. if (!CanReadFromSocket) { return 0; } throw; } catch (WebSocketException wse) when (wse.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely) { // Other party closed connection prematurely. Close(); return 0; } } /// public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { Requires.NotNull(buffer, nameof(buffer)); Requires.Range(offset >= 0 && offset < buffer.Length, nameof(offset)); Requires.Range(count >= 0 && count <= buffer.Length - offset, nameof(count)); ThrowIfCannotWrite(); var cts = cancellationToken.CanBeCanceled ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, this.writeCts.Token) : default; Task? writeTask = null; try { lock (this.lockObject) { ThrowIfCannotWrite(); writeTask = this.socket.SendAsync( new ArraySegment(buffer, offset, count), WebSocketMessageType.Binary, true /* end of message */, cancellationToken.CanBeCanceled ? cts!.Token : this.writeCts.Token); this.lastWriteTask = writeTask; } await writeTask.ConfigureAwait(false); } catch (OperationCanceledException oce) when (this.writeCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { // Other thread is trying to dispose and is cancelling this write. // Report that the object is disposed to the caller. throw new ObjectDisposedException(GetType().Name, oce); } catch (WebSocketException wse) { Close(); if (wse.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely) { // Socket closed prematurely, treat as if the connection closed. throw new ObjectDisposedException(GetType().Name, wse); } throw; } finally { if (writeTask != null) { lock (this.lockObject) { if (this.lastWriteTask == writeTask) { this.lastWriteTask = null; } } } cts?.Dispose(); } } /// public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { ThrowIfCannotWrite(); var cts = cancellationToken.CanBeCanceled ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, this.writeCts.Token) : default; Task? writeTask = null; try { lock (this.lockObject) { ThrowIfCannotWrite(); writeTask = this.socket.SendAsync( buffer, WebSocketMessageType.Binary, true /* end of message */, cancellationToken.CanBeCanceled ? cts!.Token : this.writeCts.Token).AsTask(); this.lastWriteTask = writeTask; } await writeTask.ConfigureAwait(false); lock (this.lockObject) { if (this.lastWriteTask == writeTask) { this.lastWriteTask = null; } } } catch (OperationCanceledException oce) when (this.writeCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { // Other thread is trying to dispose and is cancelling this write. // Report that the object is disposed to the caller. throw new ObjectDisposedException(GetType().Name, oce); } catch (WebSocketException wse) { Close(); if (wse.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely) { // Socket closed prematurely, treat as if the connection closed. throw new ObjectDisposedException(GetType().Name, wse); } throw; } finally { if (writeTask != null) { lock (this.lockObject) { if (this.lastWriteTask == writeTask) { this.lastWriteTask = null; } } } cts?.Dispose(); } } private bool CanReadFromSocket => (this.socket.State == WebSocketState.Open || this.socket.State == WebSocketState.CloseSent) && !this.isDisposed; private void ThrowIfCannotWrite() { ThrowIfDisposed(); if (this.socket.State != WebSocketState.Open && this.socket.State != WebSocketState.CloseReceived) { throw new ObjectDisposedException(GetType().Name); } } private void ThrowIfDisposed() { if (this.isDisposed) { throw new ObjectDisposedException(GetType().Name); } } } } dev-tunnels-0.0.25/cs/src/Contracts/000077500000000000000000000000001450757157500172165ustar00rootroot00000000000000dev-tunnels-0.0.25/cs/src/Contracts/ClusterDetails.cs000066400000000000000000000017501450757157500224770ustar00rootroot00000000000000namespace Microsoft.DevTunnels.Contracts; /// /// Details of a tunneling service cluster. Each cluster represents an instance of the /// tunneling service running in a particular Azure region. New tunnels are created in /// the current region unless otherwise specified. /// public class ClusterDetails { /// /// Initializes a new instance of the class. /// public ClusterDetails( string clusterId, string uri, string azureLocation) { ClusterId = clusterId; Uri = uri; AzureLocation = azureLocation; } /// /// A cluster identifier based on its region. /// public string ClusterId { get; } /// /// The URI of the service cluster. /// public string Uri { get; } /// /// The Azure location of the cluster. /// public string AzureLocation { get; } }dev-tunnels-0.0.25/cs/src/Contracts/DevTunnels.Contracts.csproj000066400000000000000000000022531450757157500244700ustar00rootroot00000000000000ďťż Microsoft.DevTunnels.Contracts Microsoft.DevTunnels.Contracts netcoreapp3.1;net6.0 true true false false True CS1591 <_FakeOutputPath Include="$(MSBuildProjectDirectory)\$(PackageOutputPath)\$(AssemblyName).UNK" /> dev-tunnels-0.0.25/cs/src/Contracts/ErrorCodes.cs000066400000000000000000000013461450757157500216200ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // namespace Microsoft.DevTunnels.Contracts; /// /// Error codes for ErrorDetail.Code and `x-ms-error-code` header. /// public static class ErrorCodes { /// /// Operation timed out. /// public const string Timeout = "Timeout"; /// /// Operation cannot be performed because the service is not available. /// public const string ServiceUnavailable = "ServiceUnavailable"; /// /// Internal error. /// public const string InternalError = "InternalError"; } dev-tunnels-0.0.25/cs/src/Contracts/ErrorDetail.cs000066400000000000000000000025701450757157500217650ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System.Text.Json.Serialization; namespace Microsoft.DevTunnels.Contracts; /// /// The top-level error object whose code matches the x-ms-error-code response header /// public class ErrorDetail { /// /// One of a server-defined set of error codes defined in . /// public string Code { get; set; } = null!; /// /// A human-readable representation of the error. /// public string Message { get; set; } = null!; /// /// The target of the error. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Target { get; set; } /// /// An array of details about specific errors that led to this reported error. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ErrorDetail[]? Details { get; set; } /// /// An object containing more specific information than the current object about the error. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("innererror")] public InnerErrorDetail? InnerError { get; set; } } dev-tunnels-0.0.25/cs/src/Contracts/InnerErrorDetail.cs000066400000000000000000000016361450757157500227630ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System.Text.Json.Serialization; namespace Microsoft.DevTunnels.Contracts; /// /// An object containing more specific information than the current object about the error. /// public class InnerErrorDetail { /// /// A more specific error code than was provided by the containing error. /// One of a server-defined set of error codes in . /// public string Code { get; set; } = null!; /// /// An object containing more specific information than the current object about the error. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("innererror")] public InnerErrorDetail? InnerError { get; set; } } dev-tunnels-0.0.25/cs/src/Contracts/JsonIgnoreAttribute.cs000066400000000000000000000015001450757157500235020ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; namespace Microsoft.DevTunnels.Contracts { #if !NET5_0_OR_GREATER /// /// The real `System.Text.Json.Serialization.JsonIgnoreAttribute` was added /// in .NET 5. This attribute does nothing but enables compatibility with .NET Core 3.1. /// It means JSON serialized with .NET Core 3.1 will have some extra default/null properties, /// which is generally not a problem. /// internal class JsonIgnoreAttribute : Attribute { public JsonIgnoreCondition Condition { get; set; } } internal enum JsonIgnoreCondition { WhenWritingDefault, WhenWritingNull, } #endif } dev-tunnels-0.0.25/cs/src/Contracts/LocalNetworkTunnelEndpoint.cs000066400000000000000000000030111450757157500250330ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // namespace Microsoft.DevTunnels.Contracts; /// /// Parameters for connecting to a tunnel via a local network connection. /// /// /// While a direct connection is technically not "tunneling", tunnel hosts may accept /// connections via the local network as an optional more-efficient alternative to a relay. /// public class LocalNetworkTunnelEndpoint : TunnelEndpoint { /// /// Initializes a new instance of the class. /// public LocalNetworkTunnelEndpoint() { ConnectionMode = TunnelConnectionMode.LocalNetwork; } /// /// Gets or sets a list of IP endpoints where the host may accept connections. /// /// /// A host may accept connections on multiple IP endpoints simultaneously if there /// are multiple network interfaces on the host system and/or if the host supports both /// IPv4 and IPv6. /// /// Each item in the list is a URI consisting of a scheme (which gives an indication /// of the network connection protocol), an IP address (IPv4 or IPv6) and a port number. /// The URIs do not typically include any paths, because the connection is not normally /// HTTP-based. /// public string[] HostEndpoints { get; set; } = null!; } dev-tunnels-0.0.25/cs/src/Contracts/NamedRateStatus.cs000066400000000000000000000006131450757157500226110ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // namespace Microsoft.DevTunnels.Contracts; /// /// A named . /// public class NamedRateStatus : RateStatus { /// /// The name of the rate status. /// public string? Name { get; set; } } dev-tunnels-0.0.25/cs/src/Contracts/ProblemDetails.cs000066400000000000000000000027421450757157500224600ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System.Collections.Generic; using System.Text.Json.Serialization; namespace Microsoft.DevTunnels.Contracts { /// /// Structure of error details returned by the tunnel service, including validation errors. /// /// /// This object may be returned with a response status code of 400 (or other 4xx code). /// It is compatible with RFC 7807 Problem Details (https://tools.ietf.org/html/rfc7807) and /// https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.problemdetails /// but doesn't require adding a dependency on that package. /// public class ProblemDetails { /// /// Gets or sets the error title. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Title { get; set; } /// /// Gets or sets the error detail. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Detail { get; set; } /// /// Gets or sets additional details about individual request properties. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IDictionary? Errors { get; set; } } } dev-tunnels-0.0.25/cs/src/Contracts/RateStatus.cs000066400000000000000000000041441450757157500216470ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System.Text.Json.Serialization; namespace Microsoft.DevTunnels.Contracts; /// /// Current value and limit information for a rate-limited operation related to a tunnel or port. /// public class RateStatus : ResourceStatus { /// /// Gets or sets the length of each period, in seconds, over which the rate is measured. /// /// /// For rates that are limited by month (or billing period), this value may represent /// an estimate, since the actual duration may vary by the calendar. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public uint? PeriodSeconds { get; set; } /// /// Gets or sets the unix time in seconds when this status will be reset. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public long? ResetTime { get; set; } /// public override string ToString() { var count = base.ToString(); if (PeriodSeconds == null) { return count; } if (PeriodSeconds.Value == 1) { return count + "/s"; } else if (PeriodSeconds.Value == 60) { return count + "/m"; } else if (PeriodSeconds.Value == 3600) { return count + "/h"; } else if (PeriodSeconds.Value == (24*3600)) { return count + "/d"; } else if ((PeriodSeconds.Value % (24*3600)) == 0) { return $"{count}/{(PeriodSeconds.Value / (24*3600))}d"; } else if (PeriodSeconds.Value % 3600 == 0) { return $"{count}/{PeriodSeconds.Value / 3600}h"; } else if (PeriodSeconds.Value % 60 == 0) { return $"{count}/{PeriodSeconds.Value / 60}m"; } else { return $"{count}/{PeriodSeconds.Value}s"; } } } dev-tunnels-0.0.25/cs/src/Contracts/ResourceStatus.cs000066400000000000000000000134621450757157500225460ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Diagnostics; using System.Text.Json; using System.Text.Json.Serialization; namespace Microsoft.DevTunnels.Contracts; /// /// Current value and limit for a limited resource related to a tunnel or tunnel port. /// public class ResourceStatus { /// /// Gets or sets the current value. /// public ulong Current { get; set; } /// /// Gets or sets the limit enforced by the service, or null if there is no limit. /// /// /// Any requests that would cause the limit to be exceeded may be denied by the service. /// For HTTP requests, the response is generally a 403 Forbidden status, with details about /// the limit in the response body. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ulong? Limit { get; set; } /// /// Gets or sets an optional source of the , or null if there is no limit. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? LimitSource { get; set; } /// /// Implicitly converts a number value to a resource status (with unspecified limit). /// /// public static implicit operator ResourceStatus(ulong value) => new ResourceStatus { Current = value }; /// /// Implicitly converts a resource status to a number value (ignoring any limit). /// /// public static implicit operator ulong(ResourceStatus status) => status.Current; /// public override string ToString() { return Current.ToString(); } /// /// JSON converter that allows for compatibility with a simple number value /// when the resource status does not include a limit. /// public class Converter : JsonConverter { /// #if NET5_0_OR_GREATER public override ResourceStatus? Read( #else public override ResourceStatus Read( #endif ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { // By default, serializer handles null deserialization for reference types. Debug.Assert( reader.TokenType != JsonTokenType.Null, "JSON token to be deserialized should not be null"); if (reader.TokenType == JsonTokenType.Number) { return new ResourceStatus { Current = reader.GetUInt64(), }; } if (reader.TokenType != JsonTokenType.StartObject) { throw new JsonException($"Unexpected token: {reader.TokenType}"); } else { var currentPropertyName = options.PropertyNamingPolicy?.ConvertName(nameof(Current)) ?? nameof(Current); var limitPropertyName = options.PropertyNamingPolicy?.ConvertName(nameof(Limit)) ?? nameof(Limit); var comparison = options.PropertyNameCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; ulong? current = null; ulong? limit = null; while (reader.Read()) { if (reader.TokenType == JsonTokenType.EndObject) { break; } else if (reader.TokenType != JsonTokenType.PropertyName) { throw new JsonException($"Unexpected token: {reader.TokenType}"); } var propertyName = reader.GetString(); reader.Read(); if (string.Equals(propertyName, currentPropertyName, comparison)) { current = reader.GetUInt64(); } else if (string.Equals(propertyName, limitPropertyName, comparison)) { limit = reader.TokenType == JsonTokenType.Null ? null : reader.GetUInt64(); } else { reader.Skip(); } } if (current == null) { throw new JsonException($"Missing required property: {currentPropertyName}"); } return new ResourceStatus { Current = current.Value, Limit = limit }; } } /// public override void Write( Utf8JsonWriter writer, ResourceStatus value, JsonSerializerOptions options) { // By default, serializer handles null serialization. Debug.Assert(value != null, "Value to be serialized should not be null."); if (value.Limit == null) { writer.WriteNumberValue(value.Current); } else { writer.WriteStartObject(); writer.WriteNumber( options.PropertyNamingPolicy?.ConvertName(nameof(Current)) ?? nameof(Current), value.Current); writer.WriteNumber( options.PropertyNamingPolicy?.ConvertName(nameof(Limit)) ?? nameof(Limit), value.Limit.Value); writer.WriteEndObject(); } } } } dev-tunnels-0.0.25/cs/src/Contracts/ServiceVersionDetails.cs000066400000000000000000000023011450757157500240150ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // namespace Microsoft.DevTunnels.Contracts { /// /// Data contract for service version details. /// public class ServiceVersionDetails { /// /// Gets or sets the version of the service. E.g. "1.0.6615.53976". The version corresponds to the build number. /// public string? Version { get; set; } /// /// Gets or sets the commit ID of the service. /// public string? CommitId { get; set; } /// /// Gets or sets the commit date of the service. /// public string? CommitDate { get; set; } /// /// Gets or sets the cluster ID of the service that handled the request. /// public string? ClusterId { get; set; } /// /// Gets or sets the Azure location of the service that handled the request. /// public string? AzureLocation { get; set; } } } dev-tunnels-0.0.25/cs/src/Contracts/Tunnel.cs000066400000000000000000000124501450757157500210140ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using Microsoft.DevTunnels.Contracts.Validation; namespace Microsoft.DevTunnels.Contracts; using static TunnelConstraints; /// /// Data contract for tunnel objects managed through the tunnel service REST API. /// public class Tunnel { /// /// Initializes a new instance of the class. /// public Tunnel() { } /// /// Gets or sets the ID of the cluster the tunnel was created in. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [RegularExpression(ClusterIdPattern)] [StringLength(ClusterIdMaxLength, MinimumLength = ClusterIdMinLength)] public string? ClusterId { get; set; } /// /// Gets or sets the generated ID of the tunnel, unique within the cluster. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [RegularExpression(OldTunnelIdPattern)] [StringLength(OldTunnelIdLength, MinimumLength = OldTunnelIdLength)] public string? TunnelId { get; set; } /// /// Gets or sets the optional short name (alias) of the tunnel. /// /// /// The name must be globally unique within the parent domain, and must be a valid /// subdomain. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [RegularExpression(TunnelNamePattern)] [StringLength(TunnelNameMaxLength)] public string? Name { get; set; } /// /// Gets or sets the description of the tunnel. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [StringLength(DescriptionMaxLength)] public string? Description { get; set; } /// /// Gets or sets the tags of the tunnel. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [MaxLength(MaxTags)] [ArrayStringLength(TagMaxLength, MinimumLength = TagMinLength)] [ArrayRegularExpression(TagPattern)] public string[]? Tags { get; set; } /// /// Gets or sets the optional parent domain of the tunnel, if it is not using /// the default parent domain. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [RegularExpression(TunnelDomainPattern)] [StringLength(TunnelDomainMaxLength)] public string? Domain { get; set; } /// /// Gets or sets a dictionary mapping from scopes to tunnel access tokens. /// /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IDictionary? AccessTokens { get; set; } /// /// Gets or sets access control settings for the tunnel. /// /// /// See documentation for details about the /// access control model. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TunnelAccessControl? AccessControl { get; set; } /// /// Gets or sets default options for the tunnel. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TunnelOptions? Options { get; set; } /// /// Gets or sets current connection status of the tunnel. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TunnelStatus? Status { get; set; } /// /// Gets or sets an array of endpoints where hosts are currently accepting /// client connections to the tunnel. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TunnelEndpoint[]? Endpoints { get; set; } /// /// Gets or sets a list of ports in the tunnel. /// /// /// This optional property enables getting info about all ports in a tunnel at the same time /// as getting tunnel info, or creating one or more ports at the same time as creating a /// tunnel. It is omitted when listing (multiple) tunnels, or when updating tunnel /// properties. (For the latter, use APIs to create/update/delete individual ports instead.) /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [MaxLength(TunnelMaxPorts)] public TunnelPort[]? Ports { get; set; } /// /// Gets or sets the time in UTC of tunnel creation. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DateTime? Created { get; set; } /// /// Gets or the time the tunnel will be deleted if it is not used or updated. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DateTime? Expiration { get; set; } /// /// Gets or the custom amount of time the tunnel will be valid if it is not used or updated in seconds. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public uint? CustomExpiration { get; set; } } dev-tunnels-0.0.25/cs/src/Contracts/TunnelAccessControl.cs000066400000000000000000000204731450757157500235030ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; namespace Microsoft.DevTunnels.Contracts { /// /// Data contract for access control on a or . /// /// /// Tunnels and tunnel ports can each optionally have an access-control property set on them. /// An access-control object contains a list (ACL) of entries (ACEs) that specify the /// access scopes granted or denied to some subjects. Tunnel ports inherit the ACL from the /// tunnel, though ports may include ACEs that augment or override the inherited rules. /// /// Currently there is no capability to define "roles" for tunnel access (where a role /// specifies a set of related access scopes), and assign roles to users. That feature /// may be added in the future. (It should be represented as a separate `RoleAssignments` /// property on this class.) /// /// [DebuggerDisplay("{ToString(),nq}")] public class TunnelAccessControl : IEnumerable { /// /// Initializes a new instance of the class /// with an empty list of access control entries. /// public TunnelAccessControl() { Entries = Array.Empty(); } /// /// Initializes a new instance of the class /// with a specified list of access control entries. /// public TunnelAccessControl(IEnumerable entries) { if (entries == null) { throw new ArgumentNullException(nameof(entries)); } Entries = entries.ToArray(); } /// /// Gets or sets the list of access control entries. /// /// /// The order of entries is significant: later entries override earlier entries that apply /// to the same subject. However, deny rules are always processed after allow rules, /// therefore an allow rule cannot override a deny rule for the same subject. /// [MaxLength(TunnelConstraints.AccessControlMaxEntries)] public TunnelAccessControlEntry[] Entries { get; set; } /// public IEnumerator GetEnumerator() => (Entries ?? Enumerable.Empty()).GetEnumerator(); /// System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); /// /// Checks that all items in an array of scopes are valid. /// /// List of scopes to validate. /// Optional subset of scopes to be considered valid; /// if omitted then all defined scopes are valid. /// Whether to allow multiple space-delimited scopes in a /// single item. Multiple scopes are supported when requesting a tunnel access token /// with a combination of scopes. /// A scope is not valid. public static void ValidateScopes( IEnumerable scopes, IEnumerable? validScopes = null, bool allowMultiple = false) { if (scopes == null) { throw new ArgumentNullException(nameof(scopes)); } if (allowMultiple) { scopes = scopes.SelectMany((s) => s.Split(' ')); } foreach (var scope in scopes) { if (string.IsNullOrEmpty(scope)) { throw new ArgumentException( $"Tunnel access scopes include a null/empty item.", nameof(scopes)); } else if (!TunnelAccessScopes.All.Contains(scope)) { throw new ArgumentException( $"Invalid tunnel access scope: {scope}", nameof(scopes)); } } if (validScopes != null) { foreach (var scope in scopes) { if (!validScopes.Contains(scope)) { throw new ArgumentException( $"Tunnel access scope is invalid for current request: {scope}", nameof(scopes)); } } } } /// /// Gets a compact textual representation of all the access control entries. /// public override string ToString() { return "{" + string.Join("; ", Entries) + "}"; } /// /// Workaround for System.Text.Json bug with classes that implement IEnumerable. /// See https://github.com/dotnet/runtime/issues/1808 /// public class Converter : JsonConverter { /// #if NET5_0_OR_GREATER public override TunnelAccessControl? Read( #else public override TunnelAccessControl Read( #endif ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { // By default, serializer handles null deserialization for reference types. Debug.Assert( reader.TokenType != JsonTokenType.Null, "JSON token to be deserialized should not be null"); if (reader.TokenType != JsonTokenType.StartObject) { throw new JsonException($"Unexpected token: {reader.TokenType}"); } var entriesPropertyName = options.PropertyNamingPolicy?.ConvertName(nameof(Entries)) ?? nameof(Entries); var comparison = options.PropertyNameCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; var value = new TunnelAccessControl(); while (reader.Read()) { if (reader.TokenType == JsonTokenType.EndObject) { break; } else if (reader.TokenType != JsonTokenType.PropertyName) { throw new JsonException($"Unexpected token: {reader.TokenType}"); } var propertyName = reader.GetString(); reader.Read(); if (string.Equals(propertyName, entriesPropertyName, comparison)) { value.Entries = JsonSerializer.Deserialize( ref reader, options) !; } else { reader.Skip(); } } return value; } /// public override void Write( Utf8JsonWriter writer, TunnelAccessControl value, JsonSerializerOptions options) { // By default, serializer handles null serialization. Debug.Assert(value != null, "Value to be serialized should not be null."); writer.WriteStartObject(); writer.WritePropertyName( options.PropertyNamingPolicy?.ConvertName(nameof(Entries)) ?? nameof(Entries)); JsonSerializer.Serialize(writer, value.Entries, options); writer.WriteEndObject(); } } } } dev-tunnels-0.0.25/cs/src/Contracts/TunnelAccessControlEntry.cs000066400000000000000000000217741450757157500245320ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.Text; using System.Text.Json.Serialization; using Microsoft.DevTunnels.Contracts.Validation; namespace Microsoft.DevTunnels.Contracts; using static TunnelConstraints; /// /// Data contract for an access control entry on a or /// . /// /// /// An access control entry (ACE) grants or denies one or more access scopes to one /// or more subjects. Tunnel ports inherit access control entries from their /// tunnel, and they may have additional port-specific entries that /// augment or override those access rules. /// [DebuggerDisplay("{ToString(),nq}")] public class TunnelAccessControlEntry { /// /// Constants for well-known identity providers. /// public static class Providers { internal const int MaxLength = 12; /// Microsoft (AAD) identity provider. public const string Microsoft = "microsoft"; /// GitHub identity provider. public const string GitHub = "github"; /// SSH public keys. public const string Ssh = "ssh"; /// IPv4 addresses. public const string IPv4 = "ipv4"; /// IPv6 addresses. public const string IPv6 = "ipv6"; /// Service tags. public const string ServiceTag = "service-tag"; } /// /// Initializes a new instance of the class. /// public TunnelAccessControlEntry() { Scopes = Array.Empty(); Subjects = Array.Empty(); } /// /// Gets or sets the access control entry type. /// public TunnelAccessControlEntryType Type { get; set; } /// /// Gets or sets the provider of the subjects in this access control entry. The provider /// impacts how the subject identifiers are resolved and displayed. The provider may be an /// identity provider such as AAD, or a system or standard such as "ssh" or "ipv4". /// /// /// For user, group, or org ACEs, this value is the name of the identity provider /// of the user/group/org IDs. It may be one of the well-known provider names in /// , or (in the future) a custom identity provider. /// /// For public key ACEs, this value is the type of public key, e.g. "ssh". /// /// For IP address range ACEs, this value is the IP address version, "ipv4" or "ipv6", /// or "service-tag" if the range is defined by an Azure service tag. /// /// For anonymous ACEs, this value is null. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [StringLength(Providers.MaxLength)] public string? Provider { get; set; } /// /// Gets or sets a value indicating whether this is an access control entry on a tunnel /// port that is inherited from the tunnel's access control list. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool IsInherited { get; set; } /// /// Gets or sets a value indicating whether this entry is a deny rule that blocks access /// to the specified users. Otherwise it is an allow rule. /// /// /// All deny rules (including inherited rules) are processed after all allow rules. /// Therefore a deny ACE cannot be overridden by an allow ACE that is later in the list or /// on a more-specific resource. In other words, inherited deny ACEs cannot be overridden. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool IsDeny { get; set; } /// /// Gets or sets a value indicating whether this entry applies to all subjects that are NOT /// in the list. /// /// /// Examples: an inverse organizations ACE applies to all users who are not members of /// the listed organization(s); an inverse anonymous ACE applies to all authenticated users; /// an inverse IP address ranges ACE applies to all clients that are not within any of the /// listed IP address ranges. The inverse option is often useful in policies in combination /// with , for example a policy could deny access to users who are not /// members of an organization or are outside of an IP address range, effectively blocking /// any tunnels from allowing outside access (because inherited deny ACEs cannot be /// overridden). /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool IsInverse { get; set; } /// /// Gets or sets an optional organization context for all subjects of this entry. The use /// and meaning of this value depends on the and /// of this entry. /// /// /// For AAD users and group ACEs, this value is the AAD tenant ID. It is not currently used /// with any other types of ACEs. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [StringLength(AccessControlSubjectMaxLength)] [RegularExpression(AccessControlSubjectPattern)] public string? Organization { get; set; } /// /// Gets or sets the subjects for the entry, such as user or group IDs. The format of the /// values depends on the and of this entry. /// [MaxLength(TunnelConstraints.AccessControlMaxSubjects)] [ArrayStringLength(AccessControlSubjectMaxLength)] [ArrayRegularExpression(AccessControlSubjectPattern)] public string[] Subjects { get; set; } /// /// Gets or sets the access scopes that this entry grants or denies to the subjects. /// /// /// These must be one or more values from . /// [MaxLength(AccessControlMaxScopes)] [ArrayStringLength(TunnelAccessScopes.MaxLength)] public string[] Scopes { get; set; } /// /// Gets or sets the expiration for an access control entry. /// /// /// If no value is set then this value is null. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DateTime? Expiration { get; set; } /// /// Gets a compact textual representation of the access control entry. /// public override string ToString() { var s = new StringBuilder(); if (IsInherited) { s.Append("Inherited: "); } s.Append(IsDeny ? '-' : '+'); s.Append(GetEntryTypeLabel(Type, Provider, IsInverse, plural: Subjects.Length != 1)); if (Scopes.Length > 0) { s.Append($" [{string.Join(", ", Scopes)}]"); } if (Subjects.Length > 0) { s.Append($" {(IsInverse ? "~" : string.Empty)}({string.Join(", ", Subjects)})"); } return s.ToString(); } private static string GetEntryTypeLabel( TunnelAccessControlEntryType entryType, string? provider, bool isInverse, bool plural) { if (entryType == TunnelAccessControlEntryType.Anonymous) { plural = false; } var label = entryType switch { TunnelAccessControlEntryType.Anonymous => isInverse ? "Authenticated Users" : "Anonymous", TunnelAccessControlEntryType.Users => "User", TunnelAccessControlEntryType.Groups => provider == Providers.GitHub ? "Team" : "Group", TunnelAccessControlEntryType.Organizations => provider == Providers.Microsoft ? "Tenant" : "Org", TunnelAccessControlEntryType.Repositories => "Repo", TunnelAccessControlEntryType.PublicKeys => "Key", TunnelAccessControlEntryType.IPAddressRanges => "IP Range", _ => entryType.ToString(), }; if (plural) { label += "s"; } if (!string.IsNullOrEmpty(provider)) { label = provider switch { Providers.Microsoft => $"AAD {label}", Providers.GitHub => $"GitHub {label}", Providers.Ssh => $"SSH {label}", Providers.IPv4 => label.Replace("IP", "IPv4"), Providers.IPv6 => label.Replace("IP", "IPv6"), Providers.ServiceTag => "Service Tag", _ => $"{label} ({provider})", }; } return label; } } dev-tunnels-0.0.25/cs/src/Contracts/TunnelAccessControlEntryType.cs000066400000000000000000000037661450757157500253750ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // namespace Microsoft.DevTunnels.Contracts { /// /// Specifies the type of . /// public enum TunnelAccessControlEntryType { /// /// Uninitialized access control entry type. /// None = 0, /// /// The access control entry refers to all anonymous users. /// Anonymous, /// /// The access control entry is a list of user IDs that are allowed (or denied) access. /// Users, /// /// The access control entry is a list of groups IDs that are allowed (or denied) access. /// Groups, /// /// The access control entry is a list of organization IDs that are allowed (or denied) /// access. /// /// /// All users in the organizations are allowed (or denied) access, unless overridden by /// following group or user rules. /// Organizations, /// /// The access control entry is a list of repositories. Users are allowed access to /// the tunnel if they have access to the repo. /// Repositories, /// /// The access control entry is a list of public keys. Users are allowed access if /// they can authenticate using a private key corresponding to one of the public keys. /// PublicKeys, /// /// The access control entry is a list of IP address ranges that are allowed (or denied) /// access to the tunnel. Ranges can be IPv4, IPv6, or Azure service tags. /// IPAddressRanges, } } dev-tunnels-0.0.25/cs/src/Contracts/TunnelAccessScopes.cs000066400000000000000000000041561450757157500233170ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // namespace Microsoft.DevTunnels.Contracts; /// /// Defines scopes for tunnel access tokens. /// /// /// A tunnel access token with one or more of these scopes typically also has cluster ID and /// tunnel ID claims that limit the access scope to a specific tunnel, and may also have one /// or more port claims that further limit the access to particular ports of the tunnel. /// public static class TunnelAccessScopes { internal const int MaxLength = 20; /// /// Allows creating tunnels. This scope is valid only in policies at the global, domain, /// or organization level; it is not relevant to an already-created tunnel or tunnel port. /// (Creation of ports requires "manage" or "host" access to the tunnel.) /// public const string Create = "create"; /// /// Allows management operations on tunnels and tunnel ports. /// public const string Manage = "manage"; /// /// Allows management operations on all ports of a tunnel, but does not allow updating any /// other tunnel properties or deleting the tunnel. /// public const string ManagePorts = "manage:ports"; /// /// Allows accepting connections on tunnels as a host. Includes access to update tunnel /// endpoints and ports. /// public const string Host = "host"; /// /// Allows inspecting tunnel connection activity and data. /// public const string Inspect = "inspect"; /// /// Allows connecting to tunnels or ports as a client. /// public const string Connect = "connect"; /// /// Array of all access scopes. Primarily used for validation. /// public static readonly string[] All = new[] { Create, Manage, ManagePorts, Host, Inspect, Connect, }; } dev-tunnels-0.0.25/cs/src/Contracts/TunnelAccessSubject.cs000066400000000000000000000051261450757157500234600ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; namespace Microsoft.DevTunnels.Contracts; using static TunnelConstraints; /// /// Properties about a subject of a tunnel access control entry (ACE), used when resolving /// subject names to IDs when creating new ACEs, or formatting subject IDs to names when /// displaying existing ACEs. /// public class TunnelAccessSubject { /// /// Gets or sets the type of subject, e.g. user, group, or organization. /// public TunnelAccessControlEntryType Type { get; set; } /// /// Gets or sets the subject ID. /// /// The ID is typically a guid or integer that is unique within the scope of /// the identity provider or organization, and never changes for that subject. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [StringLength(AccessControlSubjectMaxLength)] [RegularExpression(AccessControlSubjectPattern)] public string? Id { get; set; } /// /// Gets or sets the subject organization ID, which may be required if an organization is /// not implied by the authentication context. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [StringLength(AccessControlSubjectMaxLength)] [RegularExpression(AccessControlSubjectPattern)] public string? OrganizationId { get; set; } /// /// Gets or sets the partial or full subject name. /// /// /// When resolving a subject name to ID, a partial name may be provided, and the full name /// is returned if the partial name was successfully resolved. When formatting /// a subject ID to name, the full name is returned if the ID was found. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [StringLength(AccessControlSubjectNameMaxLength)] [RegularExpression(AccessControlSubjectNamePattern)] public string? Name { get; set; } /// /// Gets or sets an array of possible subject matches, if a partial name was provided /// and did not resolve to a single subject. /// /// /// This property applies only when resolving subject names to IDs. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TunnelAccessSubject[]? Matches { get; set; } } dev-tunnels-0.0.25/cs/src/Contracts/TunnelAuthenticationSchemes.cs000066400000000000000000000017011450757157500252210ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // namespace Microsoft.DevTunnels.Contracts; /// /// Defines string constants for authentication schemes supported by tunnel service APIs. /// public static class TunnelAuthenticationSchemes { /// /// Authentication scheme for AAD (or Microsoft account) access tokens. /// public const string Aad = "aad"; /// /// Authentication scheme for GitHub access tokens. /// public const string GitHub = "github"; /// /// Authentication scheme for tunnel access tokens. /// public const string Tunnel = "tunnel"; /// /// Authentication scheme for tunnelPlan access tokens. /// public const string TunnelPlan = "tunnelplan"; } dev-tunnels-0.0.25/cs/src/Contracts/TunnelConnectionMode.cs000066400000000000000000000020031450757157500236320ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // namespace Microsoft.DevTunnels.Contracts { /// /// Specifies the connection protocol / implementation for a tunnel. /// /// /// Depending on the connection mode, hosts or clients might need to use different /// authentication and connection protocols. /// public enum TunnelConnectionMode { /// /// Connect directly to the host over the local network. /// /// /// While it's technically not "tunneling", this mode may be combined /// with others to enable choosing the most efficient connection mode available. /// LocalNetwork = 0, /// /// Use the tunnel service's integrated relay function. /// TunnelRelay = 1, } } dev-tunnels-0.0.25/cs/src/Contracts/TunnelConstraints.cs000066400000000000000000000503321450757157500232450ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Text.RegularExpressions; namespace Microsoft.DevTunnels.Contracts; /// /// Tunnel constraints. /// public static class TunnelConstraints { // Note regular expression patterns must be string constants (for use in attributes), so they // cannot reference the corresponding min/max length integer constants. Be sure to also update // the regex patterns updating min/max length integer constants. /// /// Min length of tunnel cluster ID. /// /// public const int ClusterIdMinLength = 3; /// /// Max length of tunnel cluster ID. /// /// public const int ClusterIdMaxLength = 12; /// /// Length of V1 tunnel id. /// /// public const int OldTunnelIdLength = 8; /// /// Min length of V2 tunnelId. /// public const int NewTunnelIdMinLength = 3; /// /// Max length of V2 tunnelId. /// public const int NewTunnelIdMaxLength = 60; /// /// Length of a tunnel alias. /// public const int TunnelAliasLength = 8; /// /// Min length of tunnel name. /// /// public const int TunnelNameMinLength = 3; /// /// Max length of tunnel name. /// /// public const int TunnelNameMaxLength = 60; /// /// Max length of tunnel or port description. /// /// /// public const int DescriptionMaxLength = 400; /// /// Min length of a single tunnel or port tag. /// /// /// public const int TagMinLength = 1; /// /// Max length of a single tunnel or port tag. /// /// /// public const int TagMaxLength = 50; /// /// Maximum number of tags that can be applied to a tunnel or port. /// /// /// public const int MaxTags = 100; /// /// Min length of a tunnel domain. /// /// public const int TunnelDomainMinLength = 4; /// /// Max length of a tunnel domain. /// /// public const int TunnelDomainMaxLength = 180; /// /// Maximum number of items allowed in the tunnel ports array. The actual limit /// on number of ports that can be created may be much lower, and may depend on various resource /// limitations or policies. /// /// public const int TunnelMaxPorts = 1000; /// /// Maximum number of access control entries (ACEs) in a tunnel or tunnel port /// access control list (ACL). /// /// public const int AccessControlMaxEntries = 40; /// /// Maximum number of subjects (such as user IDs) in a tunnel or tunnel port /// access control entry (ACE). /// /// public const int AccessControlMaxSubjects = 100; /// /// Max length of an access control subject or organization ID. /// /// /// /// /// public const int AccessControlSubjectMaxLength = 200; /// /// Max length of an access control subject name, when resolving names to IDs. /// /// public const int AccessControlSubjectNameMaxLength = 200; /// /// Maximum number of scopes in an access control entry. /// /// public const int AccessControlMaxScopes = 10; /// /// Regular expression that can match or validate tunnel cluster ID strings. /// /// /// Cluster IDs are alphanumeric; hyphens are not permitted. /// /// public const string ClusterIdPattern = "^(([a-z]{3,4}[0-9]{1,3})|asse|aue|brs|euw|use)$"; /// /// Regular expression that can match or validate tunnel cluster ID strings. /// /// /// Cluster IDs are alphanumeric; hyphens are not permitted. /// /// public static Regex ClusterIdRegex { get; } = new Regex(ClusterIdPattern); /// /// Characters that are valid in tunnel IDs. Includes numbers and lowercase letters, /// excluding vowels and 'y' (to avoid accidentally generating any random words). /// /// public const string OldTunnelIdChars = "0123456789bcdfghjklmnpqrstvwxz"; /// /// Regular expression that can match or validate tunnel ID strings. /// /// /// Tunnel IDs are fixed-length and have a limited character set of /// numbers and lowercase letters (minus vowels and y). /// /// public const string OldTunnelIdPattern = "[" + OldTunnelIdChars + "]{8}"; /// /// Regular expression that can match or validate tunnel ID strings. /// /// /// Tunnel IDs are fixed-length and have a limited character set of /// numbers and lowercase letters (minus vowels and y). /// /// public static Regex OldTunnelIdRegex { get; } = new Regex(OldTunnelIdPattern); /// /// Characters that are valid in tunnel IDs. Includes numbers and lowercase letters, /// excluding vowels and 'y' (to avoid accidentally generating any random words). /// /// public const string NewTunnelIdChars = "0123456789abcdefghijklmnopqrstuvwxyz-"; /// /// Regular expression that can match or validate tunnel ID strings. /// /// /// Tunnel IDs are fixed-length and have a limited character set of /// numbers and lowercase letters (minus vowels and y). /// /// public const string NewTunnelIdPattern = "[a-z0-9][a-z0-9-]{1,58}[a-z0-9]"; /// /// Regular expression that can match or validate tunnel ID strings. /// /// /// Tunnel IDs are fixed-length and have a limited character set of /// numbers and lowercase letters (minus vowels and y). /// /// public static Regex NewTunnelIdRegex { get; } = new Regex(NewTunnelIdPattern); /// /// Characters that are valid in tunnel IDs. Includes numbers and lowercase letters, /// excluding vowels and 'y' (to avoid accidentally generating any random words). /// /// public const string TunnelAliasChars = "0123456789bcdfghjklmnpqrstvwxz"; /// /// Regular expression that can match or validate tunnel alias strings. /// /// /// Tunnel Aliases are fixed-length and have a limited character set of /// numbers and lowercase letters (minus vowels and y). /// /// public const string TunnelAliasPattern = "[" + TunnelAliasChars + "]{3,60}"; /// /// Regular expression that can match or validate tunnel alias strings. /// /// /// Tunnel Aliases are fixed-length and have a limited character set of /// numbers and lowercase letters (minus vowels and y). /// /// public static Regex TunnelAliasRegex { get; } = new Regex(TunnelAliasPattern); /// /// Regular expression that can match or validate tunnel names. /// /// /// Tunnel names are alphanumeric and may contain hyphens. The pattern also /// allows an empty string because tunnels may be unnamed. /// /// public const string TunnelNamePattern = "([a-z0-9][a-z0-9-]{1,58}[a-z0-9])|(^$)"; /// /// Regular expression that can match or validate tunnel names. /// /// /// Tunnel names are alphanumeric and may contain hyphens. The pattern also /// allows an empty string because tunnels may be unnamed. /// /// public static Regex TunnelNameRegex { get; } = new Regex(TunnelNamePattern); /// /// Regular expression that can match or validate tunnel or port tags. /// /// public const string TagPattern = "[\\w-=]{1,50}"; /// /// Regular expression that can match or validate tunnel or port tags. /// /// /// public static Regex TagRegex { get; } = new Regex(TagPattern); /// /// Regular expression that can match or validate tunnel domains. /// /// /// The tunnel service may perform additional contextual validation at the time the domain /// is registered. /// /// public const string TunnelDomainPattern = "[0-9a-z][0-9a-z-.]{1,158}[0-9a-z]|(^$)"; /// /// Regular expression that can match or validate tunnel domains. /// /// /// The tunnel service may perform additional contextual validation at the time the domain /// is registered. /// /// public static Regex TunnelDomainRegex { get; } = new Regex(TunnelDomainPattern); /// /// Regular expression that can match or validate an access control subject or organization ID. /// /// /// /// /// /// /// The : and / characters are allowed because subjects may include IP addresses and ranges. /// The @ character is allowed because MSA subjects may be identified by email address. /// public const string AccessControlSubjectPattern = "[0-9a-zA-Z-._:/@]{0,200}"; /// /// Regular expression that can match or validate an access control subject or organization ID. /// /// /// /// /// public static Regex AccessControlSubjectRegex { get; } = new Regex(AccessControlSubjectPattern); /// /// Regular expression that can match or validate an access control subject name, when resolving /// subject names to IDs. /// /// /// /// Note angle-brackets are only allowed when they wrap an email address as part of a /// formatted name with email. The service will block any other use of angle-brackets, /// to avoid any XSS risks. /// public const string AccessControlSubjectNamePattern = "[ \\w\\d-.,/'\"_@()<>]{0,200}"; /// /// Regular expression that can match or validate an access control subject name, when resolving /// subject names to IDs. /// /// public static Regex AccessControlSubjectNameRegex { get; } = new Regex(AccessControlSubjectNamePattern); /// /// Validates and returns true if it is a valid cluster ID, otherwise false. /// public static bool IsValidClusterId(string clusterId) { if (string.IsNullOrEmpty(clusterId)) { return false; } var m = ClusterIdRegex.Match(clusterId); return m.Index == 0 && m.Length == clusterId.Length; } /// /// Validates and returns true if it is a valid tunnel id, otherwise, false. /// public static bool IsValidOldTunnelId(string tunnelId) { if (string.IsNullOrEmpty(tunnelId) || tunnelId.Length != OldTunnelIdLength) { return false; } var m = OldTunnelIdRegex.Match(tunnelId); return m.Index == 0 && m.Length == tunnelId.Length; } /// /// Validates and returns true if it is a valid tunnel id, otherwise, false. /// public static bool IsValidNewTunnelId(string tunnelId) { if (string.IsNullOrEmpty(tunnelId) || tunnelId.Length < NewTunnelIdMinLength || tunnelId.Length > NewTunnelIdMaxLength) { return false; } var m = NewTunnelIdRegex.Match(tunnelId); return m.Index == 0 && m.Length == tunnelId?.Length && !IsValidTunnelAlias(tunnelId); } /// /// Validates and returns true if it is a valid tunnel alias, otherwise, false. /// public static bool IsValidTunnelAlias(string alias) { if (string.IsNullOrEmpty(alias) || alias.Length != TunnelAliasLength) { return false; } var m = TunnelAliasRegex.Match(alias); return (m.Index == 0 && m.Length == alias.Length); } /// /// Validates and returns true if it is a valid tunnel name, otherwise, false. /// public static bool IsValidTunnelName(string tunnelName) { if (string.IsNullOrEmpty(tunnelName)) { return false; } if (tunnelName.EndsWith("-inspect", StringComparison.OrdinalIgnoreCase)) { return false; } var m = TunnelNameRegex.Match(tunnelName); return m.Index == 0 && m.Length == tunnelName.Length && !IsValidTunnelAlias(tunnelName); } /// /// Validates and returns true if it is a valid tunnel tag, otherwise, false. /// public static bool IsValidTag(string tag) { if (string.IsNullOrEmpty(tag)) { return false; } var m = TagRegex.Match(tag); return m.Index == 0 && m.Length == tag.Length; } /// /// Validates and returns true if it is a valid tunnel id or name. /// public static bool IsValidTunnelIdOrName(string tunnelIdOrName) { if (string.IsNullOrEmpty(tunnelIdOrName)) { return false; } // Tunnel ID Regex is a subset of Tunnel name Regex var m = TunnelNameRegex.Match(tunnelIdOrName); return m.Index == 0 && m.Length == tunnelIdOrName.Length; } /// /// Validates and throws exception if it is null or not a valid tunnel id. /// Returns back if it's a valid tunnel id. /// /// If is null. /// If is not a valid tunnel id. public static string ValidateOldTunnelId(string tunnelId, string? paramName = default) { paramName ??= nameof(tunnelId); if (tunnelId == null) { throw new ArgumentNullException(paramName); } if (!IsValidOldTunnelId(tunnelId)) { throw new ArgumentException("Invalid tunnel id", paramName); } return tunnelId; } /// /// Validates and throws exception if it is null or not a valid tunnel id. /// Returns back if it's a valid tunnel id. /// /// If is null. /// If is not a valid tunnel id. public static string ValidateNewOrOldTunnelId(string tunnelId, string? paramName = default) { try { return ValidateNewTunnelId(tunnelId, paramName); } catch (ArgumentException) { return ValidateOldTunnelId(tunnelId, paramName); } } /// /// Validates and throws exception if it is null or not a valid tunnel id. /// Returns back if it's a valid tunnel id. /// /// If is null. /// If is not a valid tunnel id. public static string ValidateNewTunnelId(string tunnelId, string? paramName = default) { paramName ??= nameof(tunnelId); if (tunnelId == null) { throw new ArgumentNullException(paramName); } if (!IsValidNewTunnelId(tunnelId)) { throw new ArgumentException("Invalid tunnel id", paramName); } if (IsValidTunnelAlias(tunnelId)) { throw new ArgumentException("Tunnel id must either be not 8 characters long or have a vowel", paramName); } return tunnelId; } /// /// Validates and throws exception if it is null or not a valid tunnel id. /// Returns back if it's a valid tunnel id. /// /// If is null. /// If is not a valid tunnel id. public static string ValidateTunnelAlias(string tunnelAlias, string? paramName = default) { paramName ??= nameof(tunnelAlias); if (tunnelAlias == null) { throw new ArgumentNullException(paramName); } if (!IsValidTunnelAlias(tunnelAlias)) { throw new ArgumentException("Invalid tunnel id", paramName); } return tunnelAlias; } /// /// Validates and throws exception if it is null or not a valid tunnel id or name. /// Returns back if it's a valid tunnel id. /// /// If is null. /// If is not a valid tunnel id or name. public static string ValidateTunnelIdOrName(string tunnelIdOrName, string? paramName = default) { paramName ??= nameof(tunnelIdOrName); if (tunnelIdOrName == null) { throw new ArgumentNullException(paramName); } if (!IsValidTunnelIdOrName(tunnelIdOrName)) { throw new ArgumentException("Invalid tunnel id or name", paramName); } return tunnelIdOrName; } } dev-tunnels-0.0.25/cs/src/Contracts/TunnelContracts.cs000066400000000000000000000022351450757157500226750ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System.Text.Json; using System.Text.Json.Serialization; namespace Microsoft.DevTunnels.Contracts { /// /// Utilities for serializing and deserializing tunnel data contracts. /// public static class TunnelContracts { /// /// Gets JSON options configured for serializing and deserializing tunnel data contracts. /// public static JsonSerializerOptions JsonOptions { get; } = CreateJsonOptions(); private static JsonSerializerOptions CreateJsonOptions() { var options = new JsonSerializerOptions(); options.Converters.Add(new JsonStringEnumConverter()); options.Converters.Add(new TunnelEndpoint.Converter()); options.Converters.Add(new TunnelAccessControl.Converter()); options.Converters.Add(new ResourceStatus.Converter()); options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; return options; } } } dev-tunnels-0.0.25/cs/src/Contracts/TunnelEndpoint.cs000066400000000000000000000232151450757157500225160ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Collections.Generic; using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; namespace Microsoft.DevTunnels.Contracts; /// /// Base class for tunnel connection parameters. /// /// /// A tunnel endpoint specifies how and where hosts and clients can connect to a tunnel. /// There is a subclass for each connection mode, each having different connection /// parameters. A tunnel may have multiple endpoints for one host (or multiple hosts), /// and clients can select their preferred endpoint(s) from those depending on network /// environment or client capabilities. /// public abstract class TunnelEndpoint { // TODO: Add validation attributes on properties of this class. /// /// Gets or sets the ID of this endpoint. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Id { get; set; } /// /// Gets or sets the connection mode of the endpoint. /// /// /// This property is required when creating or updating an endpoint. /// /// The subclass type is also an indication of the connection mode, but this property /// is necessary to determine the subclass type when deserializing. /// public TunnelConnectionMode ConnectionMode { get; set; } /// /// Gets or sets the ID of the host that is listening on this endpoint. /// /// /// This property is required when creating or updating an endpoint. /// /// If the host supports multiple connection modes, the host's ID is the same for /// all the endpoints it supports. However different hosts may simultaneously accept /// connections at different endpoints for the same tunnel, if enabled in tunnel /// options. /// public string HostId { get; set; } = null!; /// /// Gets or sets an array of public keys, which can be used by clients to authenticate /// the host. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string[]? HostPublicKeys { get; set; } /// /// Gets or sets a string used to format URIs where a web client can connect to /// ports of the tunnel. The string includes a that must be /// replaced with the actual port number. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? PortUriFormat { get; set; } /// /// Gets or sets the URI where a web client can connect to the default port of the tunnel. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? TunnelUri { get; set; } /// /// Gets or sets a string used to format ssh command where ssh client can connect to /// shared ssh port of the tunnel. The string includes a that must be /// replaced with the actual port number. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? PortSshCommandFormat { get; set; } /// /// Gets or sets the Ssh command where the Ssh client can connect to the default ssh port /// of the tunnel. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? TunnelSshCommand { get; set; } /// /// Gets or sets the Ssh gateway public key which should be added to the authorized_keys /// file so that tunnel service can connect to the shared ssh server. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? SshGatewayPublicKey { get; set; } /// /// Token included in and /// that is to be replaced by a specified port number. /// public const string PortToken = "{port}"; /// /// Gets a URI where a web client can connect to a tunnel port. /// /// A tunnel endpoint containing a port URI format. /// The port number to connect to; the port is assumed to be /// separately shared by a tunnel host. /// URI for the requested port, or null if the endpoint does not support /// web client connections. /// /// Requests to the URI may result in HTTP 307 redirections, so the client may need to /// follow the redirection in order to connect to the port. /// /// If the port is not currently shared via the tunnel, or if a host is not currently /// connected to the tunnel, then requests to the port URI may result in a 502 Bad Gateway /// response. /// public static Uri? GetPortUri(TunnelEndpoint endpoint, int? portNumber) { if (portNumber == null) { if (string.IsNullOrEmpty(endpoint.TunnelUri)) { return null; } return new Uri(endpoint.TunnelUri); } if (string.IsNullOrEmpty(endpoint.PortUriFormat)) { return null; } return new Uri(endpoint.PortUriFormat.Replace( PortToken, portNumber.Value.ToString(CultureInfo.InvariantCulture))); } /// /// Gets a ssh command which can be used to connect to a tunnel ssh port. /// /// A tunnel endpoint containing a port ssh URI format. /// The port number to connect to; the port is assumed to be /// separately shared by a tunnel host. /// ssh command for the requested ssh port, or null if the endpoint does not support /// ssh client connections. /// /// SSH client on Windows/Linux/MacOS are supported. /// /// If the port is not currently shared via the tunnel, or if a host is not currently /// connected to the tunnel, then ssh connection might fail. /// public static string? GetPortSshCommand(TunnelEndpoint endpoint, int? portNumber) { if (portNumber == null) { if (string.IsNullOrEmpty(endpoint.TunnelSshCommand)) { return null; } return endpoint.TunnelSshCommand; } if (string.IsNullOrEmpty(endpoint.PortSshCommandFormat)) { return null; } return endpoint.PortSshCommandFormat.Replace( PortToken, portNumber.Value.ToString(CultureInfo.InvariantCulture)); } /// /// Enables instantiation of a subclass when deserializing. /// public class Converter : JsonConverter { /// public override bool CanConvert(Type typeToConvert) { // The custom converter is only needed when deserializing to the base class. // If the derived class is known when deserializing, then there's no need for the // custom converter. And there's never any need for the converter when serializing. return typeToConvert == typeof(TunnelEndpoint); } /// #if NET5_0_OR_GREATER public override TunnelEndpoint? Read( #else public override TunnelEndpoint Read( #endif ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { Utf8JsonReader readerClone = reader; if (readerClone.TokenType != JsonTokenType.StartObject) { throw new JsonException("Expected JSON object start."); } while (readerClone.Read() && !( readerClone.TokenType == JsonTokenType.PropertyName && string.Equals( readerClone.GetString(), nameof(ConnectionMode), StringComparison.OrdinalIgnoreCase))) { } if (readerClone.TokenType != JsonTokenType.PropertyName) { throw new JsonException( "Expected JSON connectionMode property."); } readerClone.Read(); if (readerClone.TokenType != JsonTokenType.String) { throw new JsonException("Expected JSON string value."); } var modeString = readerClone.GetString(); if (!Enum.TryParse(modeString, out var mode)) { throw new JsonException($"Invalid connection mode value: {modeString}"); } TunnelEndpoint? tunnelEndpoint = mode switch { TunnelConnectionMode.LocalNetwork => JsonSerializer.Deserialize(ref reader, options), TunnelConnectionMode.TunnelRelay => JsonSerializer.Deserialize(ref reader, options), _ => throw new JsonException($"Unsupported connection mode: {mode}") }; return tunnelEndpoint; } /// public override void Write( Utf8JsonWriter writer, TunnelEndpoint value, JsonSerializerOptions options) { JsonSerializer.Serialize(writer, value, value.GetType(), options); } } } dev-tunnels-0.0.25/cs/src/Contracts/TunnelHeaderNames.cs000066400000000000000000000026001450757157500231050ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // namespace Microsoft.DevTunnels.Contracts; /// /// Header names for http requests that Tunnel Service can handle /// public static class TunnelHeaderNames { /// /// Additional authorization header that can be passed to tunnel web forwarding to authenticate and authorize the client. /// The format of the value is the same as Authorization header that is sent to the Tunnel service by the tunnel SDK. /// Supported schemes: /// "tunnel" with the tunnel access JWT good for 'Connect' scope. /// public const string XTunnelAuthorization = "X-Tunnel-Authorization"; /// /// Request ID header that nginx ingress controller adds to all requests if it's not there. /// public const string XRequestID = "X-Request-ID"; /// /// Github Ssh public key which can be used to validate if it belongs to tunnel's owner. /// public const string XGithubSshKey = "X-Github-Ssh-Key"; /// /// Header that will skip the antiphishing page when connection to a tunnel through web forwarding. /// public const string XTunnelSkipAntiPhishingPage = "X-Tunnel-Skip-AntiPhishing-Page"; } dev-tunnels-0.0.25/cs/src/Contracts/TunnelListByRegion.cs000066400000000000000000000021131450757157500233020ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Text.Json.Serialization; namespace Microsoft.DevTunnels.Contracts; /// /// Tunnel list by region. /// public class TunnelListByRegion { /// /// Azure region name. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? RegionName { get; set; } /// /// Cluster id in the region. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ClusterId { get; set; } /// /// List of tunnels. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TunnelV2[]? Value { get; set; } /// /// Error detail if getting list of tunnels in the region failed. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ErrorDetail? Error { get; set; } } dev-tunnels-0.0.25/cs/src/Contracts/TunnelListByRegionResponse.cs000066400000000000000000000013761450757157500250330ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Text.Json.Serialization; namespace Microsoft.DevTunnels.Contracts; /// /// Data contract for response of a list tunnel by region call. /// public class TunnelListByRegionResponse { /// /// List of tunnels /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TunnelListByRegion[]? Value { get; set; } /// /// Link to get next page of results. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? NextLink { get; set; } } dev-tunnels-0.0.25/cs/src/Contracts/TunnelListResponse.cs000066400000000000000000000016431450757157500233710ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Text.Json.Serialization; namespace Microsoft.DevTunnels.Contracts; /// /// Data contract for response of a list tunnel call. /// public class TunnelListResponse { /// /// Initializes a new instance of the class. /// public TunnelListResponse() { Value = Array.Empty(); } /// /// List of tunnels /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TunnelV2[] Value { get; set; } /// /// Link to get next page of results /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? NextLink { get; set; } } dev-tunnels-0.0.25/cs/src/Contracts/TunnelOptions.cs000066400000000000000000000103101450757157500223610ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; namespace Microsoft.DevTunnels.Contracts { /// /// Data contract for or options. /// public class TunnelOptions { // Max DNS name length (255) + 1 for ':' + 5 for '65535', max port length. private const int HostHeaderMaxLength = 300; // TODO: Consider adding an option to enable multiple hosts for a tunnel. // The system supports it, but it would only be used in advanced scenarios, // and otherwise could cause confusion in case of mistakes. // When not enabled, an existing host should be disconnected when a new host connects. /// /// Gets or sets a value indicating whether web-forwarding of this tunnel can run on any cluster (region) /// without redirecting to the home cluster. /// This is only applicable if the tunnel has a name and web-forwarding uses it. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool IsGloballyAvailable { get; set; } /// /// Gets or sets a value for `Host` header rewriting to use in web-forwarding of this tunnel or port. /// By default, with this property null or empty, web-forwarding uses "localhost" to rewrite the header. /// Web-fowarding will use this property instead if it is not null or empty. /// Port-level option, if set, takes precedence over this option on the tunnel level. /// The option is ignored if IsHostHeaderUnchanged is true. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [StringLength(HostHeaderMaxLength)] public string? HostHeader { get; set; } /// /// Gets or sets a value indicating whether `Host` header is rewritten or the header value stays intact. /// By default, if false, web-forwarding rewrites the host header with the value from HostHeader property or "localhost". /// If true, the host header will be whatever the tunnel's web-forwarding host is, e.g. tunnel-name-8080.devtunnels.ms. /// Port-level option, if set, takes precedence over this option on the tunnel level. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool IsHostHeaderUnchanged { get; set; } /// /// Gets or sets a value for `Origin` header rewriting to use in web-forwarding of this tunnel or port. /// By default, with this property null or empty, web-forwarding uses "http(s)://localhost" to rewrite the header. /// Web-fowarding will use this property instead if it is not null or empty. /// Port-level option, if set, takes precedence over this option on the tunnel level. /// The option is ignored if IsOriginHeaderUnchanged is true. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [StringLength(HostHeaderMaxLength)] public string? OriginHeader { get; set; } /// /// Gets or sets a value indicating whether `Origin` header is rewritten or the header value stays intact. /// By default, if false, web-forwarding rewrites the origin header with the value from OriginHeader property or /// "http(s)://localhost". /// If true, the Origin header will be whatever the tunnel's web-forwarding Origin is, e.g. https://tunnel-name-8080.devtunnels.ms. /// Port-level option, if set, takes precedence over this option on the tunnel level. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool IsOriginHeaderUnchanged { get; set; } /// /// Gets or sets if inspection is enabled for the tunnel. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool IsInspectionEnabled { get; set; } } } dev-tunnels-0.0.25/cs/src/Contracts/TunnelPort.cs000066400000000000000000000131731450757157500216640ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using Microsoft.DevTunnels.Contracts.Validation; namespace Microsoft.DevTunnels.Contracts; using static TunnelConstraints; /// /// Data contract for tunnel port objects managed through the tunnel service REST API. /// public class TunnelPort { /// /// Initializes a new instance of the class. /// public TunnelPort() { } /// /// Gets or sets the ID of the cluster the tunnel was created in. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [RegularExpression(ClusterIdPattern)] [StringLength(ClusterIdMaxLength, MinimumLength = ClusterIdMinLength)] public string? ClusterId { get; set; } /// /// Gets or sets the generated ID of the tunnel, unique within the cluster. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [RegularExpression(OldTunnelIdPattern)] [StringLength(OldTunnelIdLength, MinimumLength = OldTunnelIdLength)] public string? TunnelId { get; set; } /// /// Gets or sets the IP port number of the tunnel port. /// public ushort PortNumber { get; set; } /// /// Gets or sets the optional short name of the port. /// /// /// The name must be unique among named ports of the same tunnel. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [RegularExpression(TunnelNamePattern)] [StringLength(TunnelNameMaxLength)] public string? Name { get; set; } /// /// Gets or sets the optional description of the port. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [StringLength(DescriptionMaxLength)] public string? Description { get; set; } /// /// Gets or sets the tags of the port. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [MaxLength(MaxTags)] [ArrayStringLength(TagMaxLength, MinimumLength = TagMinLength)] [ArrayRegularExpression(TagPattern)] public string[]? Tags { get; set; } /// /// Gets or sets the protocol of the tunnel port. /// /// /// Should be one of the string constants from . /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [StringLength(TunnelProtocol.MaxLength)] public string? Protocol { get; set; } /// /// Gets or sets a value indicating whether this port is a default port for the tunnel. /// /// /// A client that connects to a tunnel (by ID or name) without specifying a port number will /// connect to the default port for the tunnel, if a default is configured. Or if the tunnel /// has only one port then the single port is the implicit default. /// /// Selection of a default port for a connection also depends on matching the connection to the /// port , so it is possible to configure separate defaults for distinct /// protocols like and . /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool IsDefault { get; set; } /// /// Gets or sets a dictionary mapping from scopes to tunnel access tokens. /// /// /// Unlike the tokens in , these tokens are restricted /// to the individual port. /// /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IDictionary? AccessTokens { get; set; } /// /// Gets or sets access control settings for the tunnel port. /// /// /// See documentation for details about the /// access control model. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TunnelAccessControl? AccessControl { get; set; } /// /// Gets or sets options for the tunnel port. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TunnelOptions? Options { get; set; } /// /// Gets or sets current connection status of the tunnel port. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TunnelPortStatus? Status { get; set; } /// /// Gets or sets the username for the ssh service user is trying to forward. /// /// /// Should be provided if the is Ssh. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [StringLength(TunnelNameMaxLength)] public string? SshUser { get; set; } /// /// Gets or sets web forwarding URIs. /// If set, it's a list of absolute URIs where the port can be accessed with web forwarding. /// public string[]? PortForwardingUris { get; set; } /// /// Gets or sets inspection URI. /// If set, it's an absolute URIs where the port's traffic can be inspected. /// public string? InspectionUri { get; set; } } dev-tunnels-0.0.25/cs/src/Contracts/TunnelPortListResponse.cs000066400000000000000000000017151450757157500242360ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Text.Json.Serialization; namespace Microsoft.DevTunnels.Contracts; /// /// Data contract for response of a list tunnel ports call. /// public class TunnelPortListResponse { /// /// Initializes a new instance of the class. /// public TunnelPortListResponse() { Value = Array.Empty(); } /// /// List of tunnels /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TunnelPortV2[] Value { get; set; } /// /// Link to get next page of results /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? NextLink { get; set; } } dev-tunnels-0.0.25/cs/src/Contracts/TunnelPortStatus.cs000066400000000000000000000046041450757157500230670ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Text.Json.Serialization; namespace Microsoft.DevTunnels.Contracts; /// /// Data contract for status. /// public class TunnelPortStatus { /// /// Gets or sets the current value and limit for the number of clients connected to /// the port. /// /// /// This client connection count does not include non-port-specific connections such /// as SDK and SSH clients. See for /// status of those connections. /// /// This count also does not include HTTP client connections, unless they are upgraded /// to websockets. HTTP connections are counted per-request rather than per-connection: /// see . /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ResourceStatus? ClientConnectionCount { get; set; } /// /// Gets or sets the UTC date time when a client was last connected to the port, or null /// if a client has never connected. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DateTime? LastClientConnectionTime { get; set; } /// /// Gets or sets the current value and limit for the rate of client connections to the /// tunnel port. /// /// /// This client connection rate does not count non-port-specific connections such /// as SDK and SSH clients. See for /// those connection types. /// /// This also does not include HTTP connections, unless they are upgraded to websockets. /// HTTP connections are counted per-request rather than per-connection: see /// . /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public RateStatus? ClientConnectionRate { get; set; } /// /// Gets or sets the current value and limit for the rate of HTTP requests to the tunnel /// port. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public RateStatus? HttpRequestRate { get; set; } } dev-tunnels-0.0.25/cs/src/Contracts/TunnelPortV2.cs000066400000000000000000000132071450757157500220720ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using Microsoft.DevTunnels.Contracts.Validation; namespace Microsoft.DevTunnels.Contracts; using static TunnelConstraints; /// /// Data contract for tunnel port objects managed through the tunnel service REST API. /// public class TunnelPortV2 { /// /// Initializes a new instance of the class. /// public TunnelPortV2() { } /// /// Gets or sets the ID of the cluster the tunnel was created in. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [RegularExpression(ClusterIdPattern)] [StringLength(ClusterIdMaxLength, MinimumLength = ClusterIdMinLength)] public string? ClusterId { get; set; } /// /// Gets or sets the generated ID of the tunnel, unique within the cluster. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [RegularExpression(NewTunnelIdPattern)] [StringLength(NewTunnelIdMaxLength, MinimumLength = NewTunnelIdMinLength)] public string? TunnelId { get; set; } /// /// Gets or sets the IP port number of the tunnel port. /// public ushort PortNumber { get; set; } /// /// Gets or sets the optional short name of the port. /// /// /// The name must be unique among named ports of the same tunnel. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [RegularExpression(TunnelNamePattern)] [StringLength(TunnelNameMaxLength)] public string? Name { get; set; } /// /// Gets or sets the optional description of the port. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [StringLength(DescriptionMaxLength)] public string? Description { get; set; } /// /// Gets or sets the tags of the port. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [MaxLength(MaxTags)] [ArrayStringLength(TagMaxLength, MinimumLength = TagMinLength)] [ArrayRegularExpression(TagPattern)] public string[]? Labels { get; set; } /// /// Gets or sets the protocol of the tunnel port. /// /// /// Should be one of the string constants from . /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [StringLength(TunnelProtocol.MaxLength)] public string? Protocol { get; set; } /// /// Gets or sets a value indicating whether this port is a default port for the tunnel. /// /// /// A client that connects to a tunnel (by ID or name) without specifying a port number will /// connect to the default port for the tunnel, if a default is configured. Or if the tunnel /// has only one port then the single port is the implicit default. /// /// Selection of a default port for a connection also depends on matching the connection to the /// port , so it is possible to configure separate defaults for distinct /// protocols like and . /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool IsDefault { get; set; } /// /// Gets or sets a dictionary mapping from scopes to tunnel access tokens. /// /// /// Unlike the tokens in , these tokens are restricted /// to the individual port. /// /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IDictionary? AccessTokens { get; set; } /// /// Gets or sets access control settings for the tunnel port. /// /// /// See documentation for details about the /// access control model. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TunnelAccessControl? AccessControl { get; set; } /// /// Gets or sets options for the tunnel port. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TunnelOptions? Options { get; set; } /// /// Gets or sets current connection status of the tunnel port. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TunnelPortStatus? Status { get; set; } /// /// Gets or sets the username for the ssh service user is trying to forward. /// /// /// Should be provided if the is Ssh. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [StringLength(TunnelNameMaxLength)] public string? SshUser { get; set; } /// /// Gets or sets web forwarding URIs. /// If set, it's a list of absolute URIs where the port can be accessed with web forwarding. /// public string[]? PortForwardingUris { get; set; } /// /// Gets or sets inspection URI. /// If set, it's an absolute URIs where the port's traffic can be inspected. /// public string? InspectionUri { get; set; } } dev-tunnels-0.0.25/cs/src/Contracts/TunnelProtocol.cs000066400000000000000000000022161450757157500225350ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // namespace Microsoft.DevTunnels.Contracts; /// /// Defines possible values for the protocol of a . /// public static class TunnelProtocol { internal const int MaxLength = 10; /// /// The protocol is automatically detected. (TODO: Define detection semantics.) /// public const string Auto = "auto"; /// /// Unknown TCP protocol. /// public const string Tcp = "tcp"; /// /// Unknown UDP protocol. /// public const string Udp = "udp"; /// /// SSH protocol. /// public const string Ssh = "ssh"; /// /// Remote desktop protocol. /// public const string Rdp = "rdp"; /// /// HTTP protocol. /// public const string Http = "http"; /// /// HTTPS protocol. /// public const string Https = "https"; } dev-tunnels-0.0.25/cs/src/Contracts/TunnelRelayTunnelEndpoint.cs000066400000000000000000000021321450757157500246740ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System.Text.Json.Serialization; namespace Microsoft.DevTunnels.Contracts; /// /// Parameters for connecting to a tunnel via the tunnel service's built-in relay function. /// public class TunnelRelayTunnelEndpoint : TunnelEndpoint { // TODO: Add validation attributes on properties of this class. /// /// Initializes a new instance of the class. /// public TunnelRelayTunnelEndpoint() { ConnectionMode = TunnelConnectionMode.TunnelRelay; } /// /// Gets or sets the host URI. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? HostRelayUri { get; set; } /// /// Gets or sets the client URI. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ClientRelayUri { get; set; } } dev-tunnels-0.0.25/cs/src/Contracts/TunnelServiceProperties.cs000066400000000000000000000147511450757157500244200ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; namespace Microsoft.DevTunnels.Contracts; /// /// Provides environment-dependent properties about the service. /// public class TunnelServiceProperties { /// /// Global DNS name of the production tunnel service. /// internal const string ProdDnsName = "global.rel.tunnels.api.visualstudio.com"; /// /// Global DNS name of the pre-production tunnel service. /// internal const string PpeDnsName = "global.rel.tunnels.ppe.api.visualstudio.com"; /// /// Global DNS name of the development tunnel service. /// internal const string DevDnsName = "global.ci.tunnels.dev.api.visualstudio.com"; /// /// First-party app ID: `Visual Studio Tunnel Service` /// /// /// Used for authenticating AAD/MSA users, and service principals outside the AME tenant, /// in the PROD service environment. /// internal const string ProdFirstPartyAppId = "46da2f7e-b5ef-422a-88d4-2a7f9de6a0b2"; /// /// First-party app ID: `Visual Studio Tunnel Service - Test` /// /// /// Used for authenticating AAD/MSA users, and service principals outside the AME tenant, /// in the PPE and DEV service environments. /// internal const string NonProdFirstPartyAppId = "54c45752-bacd-424a-b928-652f3eca2b18"; /// /// Third-party app ID: `tunnels-prod-app-sp` /// /// /// Used for authenticating internal AAD service principals in the AME tenant, /// in the PROD service environment. /// internal const string ProdThirdPartyAppId = "ce65d243-a913-4cae-a7dd-cb52e9f77647"; /// /// Third-party app ID: `tunnels-ppe-app-sp` /// /// /// Used for authenticating internal AAD service principals in the AME tenant, /// in the PPE service environment. /// internal const string PpeThirdPartyAppId = "544167a6-f431-4518-aac6-2fd50071928e"; /// /// Third-party app ID: `tunnels-dev-app-sp` /// /// /// Used for authenticating internal AAD service principals in the corp tenant (not AME!), /// in the DEV service environment. /// internal const string DevThirdPartyAppId = "a118c979-0249-44bb-8f95-eb0457127aeb"; /// /// GitHub App Client ID for 'Visual Studio Tunnel Service' /// /// /// Used by client apps that authenticate tunnel users with GitHub, in the PROD /// service environment. /// internal const string ProdGitHubAppClientId = "Iv1.e7b89e013f801f03"; /// /// GitHub App Client ID for 'Visual Studio Tunnel Service - Test' /// /// /// Used by client apps that authenticate tunnel users with GitHub, in the PPE and DEV /// service environments. /// internal const string NonProdGitHubAppClientId = "Iv1.b231c327f1eaa229"; private TunnelServiceProperties( string serviceUri, string serviceAppId, string serviceInternalAppId, string gitHubAppClientId) { ServiceUri = serviceUri; ServiceAppId = serviceAppId; ServiceInternalAppId = serviceInternalAppId; GitHubAppClientId = gitHubAppClientId; } /// /// Gets production service properties. /// public static TunnelServiceProperties Production { get; } = new TunnelServiceProperties( $"https://{ProdDnsName}/", ProdFirstPartyAppId, ProdThirdPartyAppId, ProdGitHubAppClientId); /// /// Gets properties for the service in the staging environment (PPE). /// public static TunnelServiceProperties Staging { get; } = new TunnelServiceProperties( $"https://{PpeDnsName}/", NonProdFirstPartyAppId, PpeThirdPartyAppId, NonProdGitHubAppClientId); /// /// Gets properties for the service in the development environment. /// public static TunnelServiceProperties Development { get; } = new TunnelServiceProperties( $"https://{DevDnsName}/", NonProdFirstPartyAppId, DevThirdPartyAppId, NonProdGitHubAppClientId); /// /// Gets properties for the service in the specified environment. /// /// A service environment string from /// `Microsoft.Extensions.Hosting.Abstractions.Environments`. /// Service properties. public static TunnelServiceProperties Environment(string environmentName) { if (string.IsNullOrEmpty(environmentName)) { throw new ArgumentNullException(nameof(environmentName)); } return environmentName.ToLowerInvariant() switch { "prod" or "production" => TunnelServiceProperties.Production, "ppe" or "preprod" or "staging" => TunnelServiceProperties.Staging, "dev" or "development" => TunnelServiceProperties.Development, _ => throw new ArgumentException($"Invalid service environment: {environmentName}"), }; } /// /// Gets the base URI of the service. /// public string ServiceUri { get; } /// /// Gets the public AAD AppId for the service. /// /// /// Clients specify this AppId as the audience property when authenticating to the service. /// public string ServiceAppId { get; } /// /// Gets the internal AAD AppId for the service. /// /// /// Other internal services specify this AppId as the audience property when authenticating /// to the tunnel service. Production services must be in the AME tenant to use this appid. /// public string ServiceInternalAppId { get; } /// /// Gets the client ID for the service's GitHub app. /// /// /// Clients apps that authenticate tunnel users with GitHub specify this as the client ID /// when requesting a user token. /// public string GitHubAppClientId { get; } } dev-tunnels-0.0.25/cs/src/Contracts/TunnelStatus.cs000066400000000000000000000137601450757157500222250ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Text.Json.Serialization; namespace Microsoft.DevTunnels.Contracts; /// /// Data contract for status. /// public class TunnelStatus { /// /// Gets or sets the current value and limit for the number of ports on the tunnel. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ResourceStatus? PortCount { get; set; } /// /// Gets or sets the current value and limit for the number of hosts currently accepting /// connections to the tunnel. /// /// /// This is typically 0 or 1, but may be more than 1 if the tunnel options allow /// multiple hosts. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ResourceStatus? HostConnectionCount { get; set; } /// /// Gets or sets the UTC time when a host was last accepting connections to the tunnel, /// or null if a host has never connected. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DateTime? LastHostConnectionTime { get; set; } /// /// Gets or sets the current value and limit for the number of clients connected to /// the tunnel. /// /// /// This counts non-port-specific client connections, which is SDK and SSH clients. /// See for status of per-port client connections. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ResourceStatus? ClientConnectionCount { get; set; } /// /// Gets or sets the UTC time when a client last connected to the tunnel, or null if /// a client has never connected. /// /// /// This reports times for non-port-specific client connections, which is SDK client and /// SSH clients. See for per-port client connections. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DateTime? LastClientConnectionTime { get; set; } /// /// Gets or sets the current value and limit for the rate of client connections to the /// tunnel. /// /// /// This counts non-port-specific client connections, which is SDK client and SSH clients. /// See for status of per-port client connections. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public RateStatus? ClientConnectionRate { get; set; } /// /// Gets or sets the current value and limit for the rate of bytes being received by the tunnel /// host and uploaded by tunnel clients. /// /// /// All types of tunnel and port connections, from potentially multiple clients, can /// contribute to this rate. The reported rate may differ slightly from the rate measurable /// by applications, due to protocol overhead. Data rate status reporting is delayed by a few /// seconds, so this value is a snapshot of the data transfer rate from a few seconds earlier. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public RateStatus? UploadRate { get; set; } /// /// Gets or sets the current value and limit for the rate of bytes being sent by the tunnel /// host and downloaded by tunnel clients. /// /// /// All types of tunnel and port connections, from potentially multiple clients, can /// contribute to this rate. The reported rate may differ slightly from the rate measurable /// by applications, due to protocol overhead. Data rate status reporting is delayed by a few /// seconds, so this value is a snapshot of the data transfer rate from a few seconds earlier. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public RateStatus? DownloadRate { get; set; } /// /// Gets or sets the total number of bytes received by the tunnel host and uploaded by tunnel /// clients, over the lifetime of the tunnel. /// /// /// All types of tunnel and port connections, from potentially multiple clients, can /// contribute to this total. The reported value may differ slightly from the value measurable /// by applications, due to protocol overhead. Data transfer status reporting is delayed by /// a few seconds. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ulong? UploadTotal { get; set; } /// /// Gets or sets the total number of bytes sent by the tunnel host and downloaded by tunnel /// clients, over the lifetime of the tunnel. /// /// /// All types of tunnel and port connections, from potentially multiple clients, can /// contribute to this total. The reported value may differ slightly from the value measurable /// by applications, due to protocol overhead. Data transfer status reporting is delayed by /// a few seconds. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ulong? DownloadTotal { get; set; } /// /// Gets or sets the current value and limit for the rate of management API read operations /// for the tunnel or tunnel ports. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public RateStatus? ApiReadRate { get; set; } /// /// Gets or sets the current value and limit for the rate of management API update /// operations for the tunnel or tunnel ports. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public RateStatus? ApiUpdateRate { get; set; } } dev-tunnels-0.0.25/cs/src/Contracts/TunnelV2.cs000066400000000000000000000124661450757157500212330ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using Microsoft.DevTunnels.Contracts.Validation; namespace Microsoft.DevTunnels.Contracts; using static TunnelConstraints; /// /// Data contract for tunnel objects managed through the tunnel service REST API. /// public class TunnelV2 { /// /// Initializes a new instance of the class. /// public TunnelV2() { } /// /// Gets or sets the ID of the cluster the tunnel was created in. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [RegularExpression(ClusterIdPattern)] [StringLength(ClusterIdMaxLength, MinimumLength = ClusterIdMinLength)] public string? ClusterId { get; set; } /// /// Gets or sets the generated ID of the tunnel, unique within the cluster. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [RegularExpression(NewTunnelIdPattern)] [StringLength(NewTunnelIdMaxLength, MinimumLength = NewTunnelIdMinLength)] public string? TunnelId { get; set; } /// /// Gets or sets the optional short name (alias) of the tunnel. /// /// /// The name must be globally unique within the parent domain, and must be a valid /// subdomain. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [RegularExpression(TunnelNamePattern)] [StringLength(TunnelNameMaxLength)] public string? Name { get; set; } /// /// Gets or sets the description of the tunnel. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [StringLength(DescriptionMaxLength)] public string? Description { get; set; } /// /// Gets or sets the tags of the tunnel. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [MaxLength(MaxTags)] [ArrayStringLength(TagMaxLength, MinimumLength = TagMinLength)] [ArrayRegularExpression(TagPattern)] public string[]? Labels { get; set; } /// /// Gets or sets the optional parent domain of the tunnel, if it is not using /// the default parent domain. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [RegularExpression(TunnelDomainPattern)] [StringLength(TunnelDomainMaxLength)] public string? Domain { get; set; } /// /// Gets or sets a dictionary mapping from scopes to tunnel access tokens. /// /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IDictionary? AccessTokens { get; set; } /// /// Gets or sets access control settings for the tunnel. /// /// /// See documentation for details about the /// access control model. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TunnelAccessControl? AccessControl { get; set; } /// /// Gets or sets default options for the tunnel. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TunnelOptions? Options { get; set; } /// /// Gets or sets current connection status of the tunnel. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TunnelStatus? Status { get; set; } /// /// Gets or sets an array of endpoints where hosts are currently accepting /// client connections to the tunnel. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TunnelEndpoint[]? Endpoints { get; set; } /// /// Gets or sets a list of ports in the tunnel. /// /// /// This optional property enables getting info about all ports in a tunnel at the same time /// as getting tunnel info, or creating one or more ports at the same time as creating a /// tunnel. It is omitted when listing (multiple) tunnels, or when updating tunnel /// properties. (For the latter, use APIs to create/update/delete individual ports instead.) /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [MaxLength(TunnelMaxPorts)] public TunnelPortV2[]? Ports { get; set; } /// /// Gets or sets the time in UTC of tunnel creation. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DateTime? Created { get; set; } /// /// Gets or the time the tunnel will be deleted if it is not used or updated. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DateTime? Expiration { get; set; } /// /// Gets or the custom amount of time the tunnel will be valid if it is not used or updated in seconds. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public uint? CustomExpiration { get; set; } } dev-tunnels-0.0.25/cs/src/Contracts/Validation/000077500000000000000000000000001450757157500213105ustar00rootroot00000000000000dev-tunnels-0.0.25/cs/src/Contracts/Validation/ArrayRegularExpressionAttribute.cs000066400000000000000000000031301450757157500302000ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace Microsoft.DevTunnels.Contracts.Validation; /// /// Similar to but validates every item in an array. /// /// /// Also works with any other of strings. /// public class ArrayRegularExpressionAttribute : RegularExpressionAttribute { /// /// Initializes a new instance of the class, /// with a regular expression pattern. /// public ArrayRegularExpressionAttribute(string pattern) : base(pattern) { } /// /// Checks whether all items in an array value match the regular expression pattern. /// /// /// Null array items are not considered valid. A null array is valid unless there is also a /// applied. /// public override bool IsValid(object? value) { var values = value as IEnumerable; if (values == null) { // RequiredAttribute should be used to assert a value is not null. return true; } foreach (var s in values) { if (s == null || !base.IsValid(s)) { return false; } } return true; } } dev-tunnels-0.0.25/cs/src/Contracts/Validation/ArrayStringLengthAttribute.cs000066400000000000000000000030521450757157500271320ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace Microsoft.DevTunnels.Contracts.Validation; /// /// Similar to but validates every item in an array. /// /// /// Also works with any other of strings. /// public class ArrayStringLengthAttribute : StringLengthAttribute { /// /// Initializes a new instance of the class, /// with a maximum length. /// public ArrayStringLengthAttribute(int maximumLength) : base(maximumLength) { } /// /// Checks whether all items in an array value are a valid length. /// /// /// Null array items are not considered valid. A null array is valid unless there is also a /// applied. /// public override bool IsValid(object? value) { var values = value as IEnumerable; if (values == null) { // RequiredAttribute should be used to assert a value is not null. return true; } foreach (var s in values) { if (s == null || !base.IsValid(s)) { return false; } } return true; } } dev-tunnels-0.0.25/cs/src/Directory.Build.props000066400000000000000000000022161450757157500213460ustar00rootroot00000000000000 PreserveNewest false false Never true true snupkg dev-tunnels-0.0.25/cs/src/Directory.Build.targets000066400000000000000000000040541450757157500216560ustar00rootroot00000000000000 $(DocumentationReferenceAssemblies) --external Microsoft.DevTunnels.Contracts $(DocumentationReferenceAssemblies) --external Microsoft.DevTunnels.Management <_PdbOutputDir>$(SymbolsOutputPath)$(TargetFramework) <_PdbOutputPath>$(_PdbOutputDir)\$(TargetName).pdb dev-tunnels-0.0.25/cs/src/Management/000077500000000000000000000000001450757157500173325ustar00rootroot00000000000000dev-tunnels-0.0.25/cs/src/Management/DevTunnels.Management.csproj000066400000000000000000000017331450757157500247220ustar00rootroot00000000000000ďťż Microsoft.DevTunnels.Management Microsoft.DevTunnels.Management netcoreapp3.1;net6.0 true true false True CS1591 <_FakeOutputPath Include="$(MSBuildProjectDirectory)\$(PackageOutputPath)\$(AssemblyName).UNK" /> dev-tunnels-0.0.25/cs/src/Management/FollowRedirectsHttpHandler.cs000066400000000000000000000052611450757157500251320ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; namespace Microsoft.DevTunnels.Management; /// /// HTTP /// internal class FollowRedirectsHttpHandler : DelegatingHandler { private const string FollowRedirectsRequestPropertyName = "FollowRedirects"; public FollowRedirectsHttpHandler(HttpMessageHandler innerHandler) : base(innerHandler) { } public int MaxRedirects { get; set; } = 3; protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellation) { var response = await base.SendAsync(request, cancellation); for (int redirectCount = 0; redirectCount < MaxRedirects && (response.StatusCode == HttpStatusCode.Redirect || response.StatusCode == HttpStatusCode.TemporaryRedirect || response.StatusCode == HttpStatusCode.PermanentRedirect) && response.Headers.Location != null && IsFollowRedirectsEnabledForRequest(request); redirectCount++) { var redirectedRequest = new HttpRequestMessage { Method = request.Method, RequestUri = response.Headers.Location, Content = request.Content, }; foreach (var header in request.Headers) { redirectedRequest.Headers.Add(header.Key, header.Value); } response = await base.SendAsync(redirectedRequest, cancellation); } return response; } public static bool IsFollowRedirectsEnabledForRequest(HttpRequestMessage request) { IDictionary requestOptions; #if NET6_0_OR_GREATER requestOptions = request.Options; #else requestOptions = request.Properties; #endif if (requestOptions.TryGetValue(FollowRedirectsRequestPropertyName, out var value) && value is bool) { return (bool)value; } // Redirects are enabled by default for requests unless specifically set to false. return true; } public static void SetFollowRedirectsEnabledForRequest(HttpRequestMessage request, bool value) { IDictionary requestOptions; #if NET6_0_OR_GREATER requestOptions = request.Options; #else requestOptions = request.Properties; #endif requestOptions[FollowRedirectsRequestPropertyName] = value; } } dev-tunnels-0.0.25/cs/src/Management/HttpContentJsonExtensions.cs000066400000000000000000000122311450757157500250440ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.IO; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; namespace Microsoft.DevTunnels.Management { #if !NET5_0_OR_GREATER /// /// The real `System.Net.Http.Json.HttpContentJsonExtensions` was added in .NET 5. /// This class enables compatibility with .NET Core 3.1. /// internal static class HttpContentJsonExtensions { private const string JsonContentType = "application/json"; public static Task ReadFromJsonAsync( this HttpContent content, JsonSerializerOptions? options = null, CancellationToken cancellationToken = default) { if (content == null) { throw new ArgumentNullException(nameof(content)); } Encoding? sourceEncoding = GetEncoding(content.Headers.ContentType?.CharSet); return ReadFromJsonAsyncCore(content, sourceEncoding, options, cancellationToken); } private static async Task ReadFromJsonAsyncCore( HttpContent content, Encoding? sourceEncoding, JsonSerializerOptions? options, CancellationToken cancellationToken) { using (Stream contentStream = await GetContentStream( content, sourceEncoding, cancellationToken).ConfigureAwait(false)) { return await JsonSerializer.DeserializeAsync( contentStream, options ?? new JsonSerializerOptions(), cancellationToken) .ConfigureAwait(false); } } public static Task PutAsJsonAsync( this HttpClient client, Uri? requestUri, T value, JsonSerializerOptions? options = null, CancellationToken cancellationToken = default) { if (client == null) { throw new ArgumentNullException(nameof(client)); } // Here the real HttpContentJsonExtensions streams the serialization, which involves more code. // For back-compat, this just converts the value to a string, which is simpler. var content = new StringContent( JsonSerializer.Serialize(value, options), Encoding.UTF8, JsonContentType); return client.PutAsync(requestUri, content, cancellationToken); } public static Task PostAsJsonAsync( this HttpClient client, Uri? requestUri, T value, JsonSerializerOptions? options = null, CancellationToken cancellationToken = default) { if (client == null) { throw new ArgumentNullException(nameof(client)); } // Here the real HttpContentJsonExtensions streams the serialization, which involves more code. // For back-compat, this just converts the value to a string, which is simpler. var content = new StringContent( JsonSerializer.Serialize(value, options), Encoding.UTF8, JsonContentType); return client.PostAsync(requestUri, content, cancellationToken); } private static async Task GetContentStream( HttpContent content, Encoding? sourceEncoding, CancellationToken cancellationToken) { Stream contentStream = await content.ReadAsStreamAsync().ConfigureAwait(false); // Wrap content stream into a transcoding stream that buffers the data transcoded // from the sourceEncoding to utf-8. if (sourceEncoding != null && sourceEncoding != Encoding.UTF8) { // Here the real HttpContentJsonExtensions class supports transcoding. // But it's not necessary for the limited back-compat scenarios. throw new NotSupportedException("Only UTF8 encoding is supported."); } return contentStream; } internal static Encoding? GetEncoding(string? charset) { Encoding? encoding = null; if (charset != null) { try { // Remove at most a single set of quotes. if (charset.Length > 2 && charset[0] == '\"' && charset[charset.Length - 1] == '\"') { encoding = Encoding.GetEncoding(charset.Substring(1, charset.Length - 2)); } else { encoding = Encoding.GetEncoding(charset); } } catch (ArgumentException e) { throw new InvalidOperationException("Invalid charset.", e); } } return encoding; } } #endif } dev-tunnels-0.0.25/cs/src/Management/ITunnelManagementClient.cs000066400000000000000000000467371450757157500244140ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Microsoft.DevTunnels.Contracts; namespace Microsoft.DevTunnels.Management { /// /// Interface for a client that manages tunnels and tunnel ports via the tunnel service /// management API. /// public interface ITunnelManagementClient : IDisposable { /// /// Lists tunnels that are owned by the caller. /// /// A tunnel cluster ID, or null to list tunnels globally. /// Tunnel domain, or null for the default domain. /// Request options. /// If authenticated with a tunnel plan token, only show the tunnels the user owns. /// Cancellation token. /// Array of tunnel objects. /// The client access token was missing, /// invalid, or unauthorized. /// /// The list can be filtered by setting . /// Ports will not be included in the returned tunnels unless /// is set to true. /// Task ListTunnelsAsync( string? clusterId = null, string? domain = null, TunnelRequestOptions? options = null, bool? ownedTunnelsOnly = null, CancellationToken cancellation = default); /// /// Search for all tunnels with matching tags. /// /// The tags that will be searched for /// If a tunnel must have all tags that are being searched for. /// A tunnel cluster ID, or null to list tunnels globally. /// Tunnel domain, or null for the default domain. /// Request options. /// Cancellation token. /// Array of tunnel objects. /// The client access token was missing, /// invalid, or unauthorized. [Obsolete("Use ListTunnelsAsync() method with TunnelRequestOptions.Tags instead.")] Task SearchTunnelsAsync( string[] tags, bool requireAllTags, string? clusterId = null, string? domain = null, TunnelRequestOptions? options = null, CancellationToken cancellation = default); /// /// Gets one tunnel by ID or name. /// /// Tunnel object including at least either a tunnel name /// (globally unique, if configured) or tunnel ID and cluster ID. /// Request options. /// Cancellation token. /// The requested tunnel object, or null if the ID or name was not found. /// The client access token was missing, /// invalid, or unauthorized. /// /// Ports will not be included in the returned tunnel unless /// is set to true. /// Task GetTunnelAsync( Tunnel tunnel, TunnelRequestOptions? options = null, CancellationToken cancellation = default); /// /// Creates a tunnel. /// /// Tunnel object including all required properties. /// Request options. /// Cancellation token. /// The created tunnel object. /// /// Ports may be created at the same time as creating the tunnel by supplying /// items in the array. /// /// The client access token was missing, /// invalid, or unauthorized. /// A required property was missing, or a property /// value was invalid. Task CreateTunnelAsync( Tunnel tunnel, TunnelRequestOptions? options = null, CancellationToken cancellation = default); /// /// Updates properties of a tunnel. /// /// Tunnel object including at least either a tunnel name /// (globally unique, if configured) or tunnel ID and cluster ID. Any non-null /// properties on the object will be updated; null properties will not be modified. /// Request options. /// Cancellation token. /// Updated tunnel object, including both updated and unmodified /// properties. /// The client access token was missing, /// invalid, or unauthorized. /// The tunnel ID or name was not found, /// or there was a conflict when updating the tunnel name. (The inner /// status code may distinguish between these cases.) /// /// An updated property value was invalid. Task UpdateTunnelAsync( Tunnel tunnel, TunnelRequestOptions? options = null, CancellationToken cancellation = default); /// /// Deletes a tunnel. /// /// Tunnel object including at least either a tunnel name /// (globally unique, if configured) or tunnel ID and cluster ID. /// Request options. /// Cancellation token. /// True if the tunnel was deleted; false if it was not found. /// The client access token was missing, /// invalid, or unauthorized. Task DeleteTunnelAsync( Tunnel tunnel, TunnelRequestOptions? options = null, CancellationToken cancellation = default); /// /// Creates or updates an endpoint for the tunnel. /// /// Tunnel object including at least either a tunnel name /// (globally unique, if configured) or tunnel ID and cluster ID. /// Endpoint object to add or update, including at least /// connection mode and host ID properties. /// Request options. /// Cancellation token. /// The created or updated tunnel endpoint, with any server-supplied /// properties filled. /// A required property was missing, or a property /// value was invalid. /// The client access token was missing, /// invalid, or unauthorized. /// The tunnel ID or name was not found. /// /// /// A tunnel endpoint specifies how and where hosts and clients can connect to a tunnel. /// Hosts create one or more endpoints when they start accepting connections on a tunnel, /// and delete the endpoints when they stop accepting connections. /// Task UpdateTunnelEndpointAsync( Tunnel tunnel, TunnelEndpoint endpoint, TunnelRequestOptions? options = null, CancellationToken cancellation = default); /// /// Deletes a tunnel endpoint. /// /// Tunnel object including at least either a tunnel name /// (globally unique, if configured) or tunnel ID and cluster ID. /// Required ID of the host for endpoint(s) to be deleted. /// Optional connection mode for endpoint(s) to be deleted, /// or null to delete endpoints for all connection modes. /// Request options. /// Cancellation token. /// True if one or more endpoints were deleted, false if none were found. /// The client access token was missing, /// invalid, or unauthorized. /// The tunnel ID or name was not found. /// /// /// Hosts create one or more endpoints when they start accepting connections on a tunnel, /// and delete the endpoints when they stop accepting connections. /// Task DeleteTunnelEndpointsAsync( Tunnel tunnel, string hostId, TunnelConnectionMode? connectionMode, TunnelRequestOptions? options = null, CancellationToken cancellation = default); /// /// Lists ports on a tunnel. /// /// Tunnel object including at least either a tunnel name /// (globally unique, if configured) or tunnel ID and cluster ID. /// Request options. /// Cancellation token. /// Array of tunnel port objects. /// The client access token was missing, /// invalid, or unauthorized. /// The tunnel ID or name was not found. /// /// /// The list can be filtered by setting . /// Task ListTunnelPortsAsync( Tunnel tunnel, TunnelRequestOptions? options = null, CancellationToken cancellation = default); /// /// Gets one port on a tunnel by port number. /// /// Tunnel object including at least either a tunnel name /// (globally unique, if configured) or tunnel ID and cluster ID. /// Port number. /// Request options. /// Cancellation token. /// The requested tunnel port object, or null if the port number /// was not found. /// The client access token was missing, /// invalid, or unauthorized. /// The tunnel ID or name was not found. /// Task GetTunnelPortAsync( Tunnel tunnel, ushort portNumber, TunnelRequestOptions? options = null, CancellationToken cancellation = default); /// /// Creates a tunnel port. /// /// Tunnel object including at least either a tunnel name /// (globally unique, if configured) or tunnel ID and cluster ID. /// Tunnel port object including all required properties. /// Request options. /// Cancellation token. /// The created tunnel port object. /// The client access token was missing, /// invalid, or unauthorized. /// The tunnel ID or name was not found, /// or a port with the specified port number already exists. (The inner /// status code may distinguish between these cases.) /// /// A required property was missing, or a property /// value was invalid. Task CreateTunnelPortAsync( Tunnel tunnel, TunnelPort tunnelPort, TunnelRequestOptions? options = null, CancellationToken cancellation = default); /// /// Updates properties of a tunnel port. /// /// Tunnel object including at least either a tunnel name /// (globally unique, if configured) or tunnel ID and cluster ID. /// Tunnel port object including at least a port number. /// Any additional non-null properties on the object will be updated; null properties /// will not be modified. /// Request options. /// Cancellation token. /// Updated tunnel port object, including both updated and unmodified /// properties. /// The client access token was missing, /// invalid, or unauthorized. /// The tunnel ID or name was not found, the /// port was not found, or there was a conflict when updating the tunnel name. (The inner /// status code may distinguish between these cases.) /// /// An updated property value was invalid. Task UpdateTunnelPortAsync( Tunnel tunnel, TunnelPort tunnelPort, TunnelRequestOptions? options = null, CancellationToken cancellation = default); /// /// Deletes a tunnel port. /// /// Tunnel object including at least either a tunnel name /// (globally unique, if configured) or tunnel ID and cluster ID. /// Port number of the port to delete. /// Request options. /// Cancellation token. /// True if the tunnel port was deleted; false if it was not found. /// The client access token was missing, /// invalid, or unauthorized. Task DeleteTunnelPortAsync( Tunnel tunnel, ushort portNumber, TunnelRequestOptions? options = null, CancellationToken cancellation = default); /// /// Looks up and formats subject names for display. /// /// Array of /// objects with values to be formatted. For AAD the /// IDs are user or group object ID GUIDs; for GitHub they are user or team ID /// integers. /// Request options. /// Cancellation token. /// Array of the same length as , where each item /// includes the formatted , or a null name value /// if the subject ID was not found. /// /// If the caller is not authenticated via the same identity provider as a subject /// (or for AAD, is not authenticated in the same AAD tenant) then the subject cannot /// be formatted, and a null name result is returned for that item. /// Task FormatSubjectsAsync( TunnelAccessSubject[] subjects, TunnelRequestOptions? options = null, CancellationToken cancellation = default); /// /// Resolves partial or full subject display names or emails to IDs. /// /// Array of /// objects whose values are partial or full names /// to be resolved to IDs. For AAD the subjects are user or group emails or display names; /// for GitHub they are user or team names or display names. /// Request options. /// Cancellation token. /// Array of the same length as , where each item /// includes the resolved and full /// , or null ID value if no match was found, or an /// array of potential matches if more than one match was found. /// /// If the caller is not authenticated via the same identity provider as a subject /// (or for AAD, is not authenticated in the same AAD tenant) then the subject cannot /// be resolved, and a null ID result is returned for that item. /// Task ResolveSubjectsAsync( TunnelAccessSubject[] subjects, TunnelRequestOptions? options = null, CancellationToken cancellation = default); /// /// Lists current consumption status and limits applied to the calling user. /// /// Cancellation token. /// Array of . Task ListUserLimitsAsync(CancellationToken cancellation = default); /// /// Lists details of tunneling service clusters in all supported Azure regions. /// /// Cancellation token. /// Array of Task ListClustersAsync(CancellationToken cancellation = default); /// /// Checks for tunnel name availability. /// /// Tunnel name to check. /// Cancellation token. /// True if the name is available; false if it is already in use. Task CheckNameAvailabilityAsync( string name, CancellationToken cancellation = default); } } dev-tunnels-0.0.25/cs/src/Management/JsonContent.cs000066400000000000000000000115751450757157500221360ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Diagnostics; using System.IO; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; namespace Microsoft.DevTunnels.Management { #if !NET5_0_OR_GREATER /// /// The real `System.Net.Http.Json.JsonContent` was added in .NET 5. /// This class enables compatibility with .NET Core 3.1. /// internal sealed class JsonContent : HttpContent { private static readonly MediaTypeHeaderValue DefaultMediaTypeHeaderValue = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" }; private static readonly JsonSerializerOptions DefaultSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; private readonly JsonSerializerOptions? jsonSerializerOptions; public Type ObjectType { get; } public object? Value { get; } private JsonContent( object? inputValue, Type inputType, MediaTypeHeaderValue? mediaType, JsonSerializerOptions? options) { if (inputType == null) { throw new ArgumentNullException(nameof(inputType)); } if (inputValue != null && !inputType.IsAssignableFrom(inputValue.GetType())) { throw new ArgumentException("Invalid input type: " + inputValue.GetType()); } Value = inputValue; ObjectType = inputType; Headers.ContentType = mediaType ?? DefaultMediaTypeHeaderValue; jsonSerializerOptions = options ?? DefaultSerializerOptions; } public static JsonContent Create(T inputValue, MediaTypeHeaderValue? mediaType = null, JsonSerializerOptions? options = null) => Create(inputValue, typeof(T), mediaType, options); public static JsonContent Create(object? inputValue, Type inputType, MediaTypeHeaderValue? mediaType = null, JsonSerializerOptions? options = null) => new JsonContent(inputValue, inputType, mediaType, options); protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => SerializeToStreamAsyncCore(stream, async: true, CancellationToken.None); protected override bool TryComputeLength(out long length) { length = 0; return false; } private async Task SerializeToStreamAsyncCore(Stream targetStream, bool async, CancellationToken cancellationToken) { Encoding? targetEncoding = GetEncoding(Headers.ContentType?.CharSet); // Wrap provided stream into a transcoding stream that buffers the data transcoded from utf-8 to the targetEncoding. if (targetEncoding != null && targetEncoding != Encoding.UTF8) { // Here the real JsonContent class supports transcoding. // But it's not necessary for the limited back-compat scenarios. throw new NotSupportedException("Only UTF8 encoding is supported."); } else { if (async) { await SerializeAsyncHelper(targetStream, Value, ObjectType, jsonSerializerOptions, cancellationToken).ConfigureAwait(false); } else { Debug.Fail("Synchronous serialization is only supported since .NET 5.0"); } } static Task SerializeAsyncHelper(Stream utf8Json, object? value, Type inputType, JsonSerializerOptions? options, CancellationToken cancellationToken) => JsonSerializer.SerializeAsync(utf8Json, value, inputType, options, cancellationToken); } private static Encoding? GetEncoding(string? charset) { Encoding? encoding = null; if (charset != null) { try { // Remove at most a single set of quotes. if (charset.Length > 2 && charset[0] == '\"' && charset[charset.Length - 1] == '\"') { encoding = Encoding.GetEncoding(charset.Substring(1, charset.Length - 2)); } else { encoding = Encoding.GetEncoding(charset); } } catch (ArgumentException e) { throw new InvalidOperationException("Invalid charset.", e); } } return encoding; } } #endif } dev-tunnels-0.0.25/cs/src/Management/TraceSourceExtensions.cs000066400000000000000000000074651450757157500241740ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System.ComponentModel; using System.Diagnostics; namespace Microsoft.DevTunnels.Management { /// /// Extension methods for tracing with a . /// [EditorBrowsable(EditorBrowsableState.Never)] // Exclude from generated documentation public static class TraceSourceExtensions { /// /// Creates a new TraceSource with listeners and switch copied from the /// existing TraceSource. /// public static TraceSource WithName(this TraceSource trace, string name) { Requires.NotNull(trace, nameof(trace)); var newTraceSource = new TraceSource(name); newTraceSource.Listeners.Clear(); // Remove the DefaultTraceListener newTraceSource.Listeners.AddRange(trace.Listeners); newTraceSource.Switch = trace.Switch; return newTraceSource; } /// Traces a critical message. [Conditional("TRACE")] public static void Critical(this TraceSource trace, string message) { trace.TraceEvent(TraceEventType.Critical, 0, message); } /// Traces a critical message with formatted arguments. [Conditional("TRACE")] public static void Critical(this TraceSource trace, string format, params object[] args) { trace.TraceEvent(TraceEventType.Critical, 0, format, args); } /// Traces an error message. [Conditional("TRACE")] public static void Error(this TraceSource trace, string message) { trace.TraceEvent(TraceEventType.Error, 0, message); } /// Traces an error message with formatted arguments. [Conditional("TRACE")] public static void Error(this TraceSource trace, string format, params object[] args) { trace.TraceEvent(TraceEventType.Error, 0, format, args); } /// Traces a warning message. [Conditional("TRACE")] public static void Warning(this TraceSource trace, string message) { trace.TraceEvent(TraceEventType.Warning, 0, message); } /// Traces a warning message with formatted arguments. [Conditional("TRACE")] public static void Warning(this TraceSource trace, string format, params object[] args) { trace.TraceEvent(TraceEventType.Warning, 0, format, args); } /// Traces an informational message. [Conditional("TRACE")] public static void Info(this TraceSource trace, string message) { trace.TraceEvent(TraceEventType.Information, 0, message); } /// Traces an informational message with formatted arguments. [Conditional("TRACE")] public static void Info(this TraceSource trace, string format, params object[] args) { trace.TraceEvent(TraceEventType.Information, 0, format, args); } /// Traces a verbose message. [Conditional("TRACE")] public static void Verbose(this TraceSource trace, string message) { trace.TraceEvent(TraceEventType.Verbose, 0, message); } /// Traces a verbose message with formatted arguments. [Conditional("TRACE")] public static void Verbose(this TraceSource trace, string format, params object[] args) { trace.TraceEvent(TraceEventType.Verbose, 0, format, args); } } } dev-tunnels-0.0.25/cs/src/Management/TunnelAccessControlExtensions.cs000066400000000000000000000226751450757157500257050ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Collections.Generic; using System.Linq; using Microsoft.DevTunnels.Contracts; namespace Microsoft.DevTunnels.Management { /// /// Extension methods for working with and /// its list of entries. /// public static class TunnelAccessControlExtensions { /// /// Resets to default access control by removing all non-inherited entries. /// public static void Reset(this TunnelAccessControl accessControl) { Requires.NotNull(accessControl, nameof(accessControl)); lock (accessControl) { var entries = accessControl.ToList(); entries.RemoveAll((ace) => !ace.IsInherited); accessControl.Entries = entries.ToArray(); } } /// /// Adds an entry to the access control list that allows access to a list of users /// or credentials, overriding any inherited or existing entry for the same users. /// public static void Allow( this TunnelAccessControl accessControl, TunnelAccessControlEntryType entryType, string? provider, string[] subjects, params string[] scopes) { Requires.NotNull(accessControl, nameof(accessControl)); ValidateEntryType(entryType); ValidateSubjects(subjects, entryType); ValidateScopes(scopes); lock (accessControl) { var entries = accessControl.ToList(); entries.Add(new TunnelAccessControlEntry { Type = entryType, Provider = provider, Scopes = new List(scopes).ToArray(), Subjects = new List(subjects).ToArray(), IsDeny = false, }); accessControl.Entries = entries.ToArray(); } } /// /// Adds an entry to the access control list that denies access to a list of users /// or credentials, overriding any inherited or existing entry for the same users. /// public static void Deny( this TunnelAccessControl accessControl, TunnelAccessControlEntryType entryType, string? provider, string[] subjects, params string[] scopes) { Requires.NotNull(accessControl, nameof(accessControl)); ValidateEntryType(entryType); ValidateSubjects(subjects, entryType); ValidateScopes(scopes); lock (accessControl) { var entries = accessControl.ToList(); entries.Add(new TunnelAccessControlEntry { Type = entryType, Provider = provider, Scopes = new List(scopes).ToArray(), Subjects = new List(subjects).ToArray(), IsDeny = true, }); accessControl.Entries = entries.ToArray(); } } /// /// Checks whether a subject is allowed a specified scope of access. /// /// /// True if access is allowed, false if access is denied, or null if there is /// no applicable access control entry. /// /// /// Entries are evaluated in order, with later entries overriding earlier entries. /// All allow rules are processed first, followed by all deny rules. This ensures an /// inherited deny rule cannot be overridden at a lower level. /// /// Warning: This does not consider whether a user may be allowed (or denied) access due to /// group or organization membership. It only scans access control entries of the specified /// type. It may be necessary to separately check group or org access control entry types. /// /// Generally no entry (null return value) should be handled the same as denial, /// but the difference might be relevant for logging/auditing. /// public static bool? IsAllowed( this TunnelAccessControl accessControl, TunnelAccessControlEntryType entryType, string subject, string scope) { Requires.NotNull(accessControl, nameof(accessControl)); ValidateEntryType(entryType); if (entryType != TunnelAccessControlEntryType.Anonymous) { Requires.NotNullOrEmpty(subject, nameof(subject)); } ValidateScopes(new[] { scope }); bool? allowed = null; foreach (var ace in accessControl) { if (!ace.IsDeny && IsEntryMatch(ace, entryType, subject, scope)) { allowed = true; break; } } foreach (var ace in accessControl) { if (ace.IsDeny && IsEntryMatch(ace, entryType, subject, scope)) { allowed = false; break; } } return allowed; } /// /// Checks if an access control entry matches the specified entry type, subject, and scope. /// private static bool IsEntryMatch( TunnelAccessControlEntry ace, TunnelAccessControlEntryType entryType, string subject, string scope) { return ace.Type == entryType && (string.IsNullOrEmpty(subject) || ace.Subjects.Contains(subject) != ace.IsInverse) && ace.Scopes.Contains(scope); } /// /// Adds an access control entry that allows anonymous users, overriding any /// inherited or existing entry for anonymous users. /// public static void AllowAnonymous(this TunnelAccessControl accessControl, string scope) { Allow( accessControl, TunnelAccessControlEntryType.Anonymous, provider: null, Array.Empty(), scope); } /// /// Adds an access control entry that denies anonymous users, overriding any /// inherited or existing entry for anonymous users. /// public static void DenyAnonymous(this TunnelAccessControl accessControl, string scope) { Deny( accessControl, TunnelAccessControlEntryType.Anonymous, provider: null, Array.Empty(), scope); } /// /// Checks whether anonymous users are allowed access. /// /// /// True if access is allowed, false if access is denied, or null if there is /// no applicable access control entry. /// /// /// Entries are evaluated in order, with later entries overriding earlier entries. /// /// Generally no entry (null return value) should be handled the same as denial, /// but the difference might be relevant for logging/auditing. /// public static bool? IsAnonymousAllowed(this TunnelAccessControl accessControl, string scope) { return IsAllowed( accessControl, TunnelAccessControlEntryType.Anonymous, string.Empty, scope); } private static void ValidateEntryType(TunnelAccessControlEntryType entryType) { Requires.Argument( Enum.IsDefined(typeof(TunnelAccessControlEntryType), entryType), nameof(entryType), "Entry type is invalid."); Requires.Argument( entryType != TunnelAccessControlEntryType.None, nameof(entryType), "Entry type is uninitialized."); } private static void ValidateSubjects( string[] subjects, TunnelAccessControlEntryType entryType) { Requires.NotNull(subjects, nameof(subjects)); if (entryType == TunnelAccessControlEntryType.Anonymous) { Requires.Argument( subjects.Length == 0, nameof(subjects), "Subjects array must be empty for an anonymous entry."); } else { Requires.Argument( subjects.Length > 0, nameof(subjects), "Subjects array must not be empty."); } } private static void ValidateScopes(string[] scopes) { Requires.NotNull(scopes, nameof(scopes)); Requires.Argument( scopes.Length > 0, nameof(scopes), "Scopes array must not be empty."); TunnelAccessControl.ValidateScopes(scopes); } } } dev-tunnels-0.0.25/cs/src/Management/TunnelAccessTokenProperties.cs000066400000000000000000000205551450757157500253350ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // using System; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace Microsoft.DevTunnels.Management; /// /// Supports parsing tunnel access token JWT properties to allow for some pre-validation /// and diagnostics. /// /// /// Applications generally should not attempt to interpret or rely on any token properties /// other than , because the service /// may change or omit those claims in the future. Other claims are exposed here only for /// diagnostic purposes. /// public class TunnelAccessTokenProperties { private const string ClusterIdClaimName = "clusterId"; private const string TunnelIdClaimName = "tunnelId"; private const string TunnelPortClaimName = "tunnelPort"; private const string ScopeClaimName = "scp"; private const string IssuerClaimName = "iss"; private const string ExpirationClaimName = "exp"; /// /// Gets the token cluster ID claim. /// public string? ClusterId { get; private set; } /// /// Gets the token tunnel ID claim. /// public string? TunnelId { get; private set; } /// /// Gets the token tunnel ports claim. /// public ushort[]? TunnelPorts { get; private set; } /// /// Gets the token scopes claim. /// public string[]? Scopes { get; private set; } /// /// Gets the token issuer URI. /// public string? Issuer { get; private set; } /// /// Gets the token expiration. /// [JsonIgnore] public DateTime? Expiration { get; private set; } /// public override string ToString() { var s = new StringBuilder(); if (!string.IsNullOrEmpty(TunnelId)) { s.Append("tunnel="); s.Append(TunnelId); if (!string.IsNullOrEmpty(ClusterId)) { s.Append('.'); s.Append(ClusterId); } } if (TunnelPorts != null) { if (s.Length > 0) s.Append(", "); if (TunnelPorts.Length == 1) { s.Append("port="); s.Append(TunnelPorts[0]); } else { s.AppendFormat("ports=[{0}]", string.Join(", ", TunnelPorts)); } } var scopes = Scopes; if (scopes != null) { if (s.Length > 0) s.Append(", "); s.AppendFormat("scopes=[{0}]", string.Join(", ", scopes)); } if (!string.IsNullOrEmpty(Issuer)) { if (s.Length > 0) s.Append(", "); s.Append("issuer="); s.Append(Issuer); } var expiration = Expiration; if (expiration != null) { if (s.Length > 0) s.Append(", "); // Get the current date-time without fractional seconds. var nowTicks = DateTime.UtcNow.Ticks; var now = new DateTime(nowTicks - nowTicks % 10000000, DateTimeKind.Utc); var lifetime = expiration.Value >= now ? (expiration.Value - now).ToString() + " remaining" : (now - expiration.Value) + " ago"; s.AppendFormat("expiration={0:s}Z ({1})", expiration.Value, lifetime); } return s.ToString(); } /// /// Checks if the tunnel access token expiration claim is in the past. /// /// The token is expired. /// Note this does not throw if the token is an invalid format. public static void ValidateTokenExpiration(string token) { var t = TryParse(token); if (t?.Expiration <= DateTime.UtcNow) { throw new UnauthorizedAccessException("The access token is expired: " + t); } } /// /// Gets token representation for tracing. /// public static string GetTokenTrace(string? token) { if (token == null) { return ""; } if (token == string.Empty) { return ""; } return TryParse(token) is TunnelAccessTokenProperties t ? $"" : ""; } /// /// Attempts to parse a tunnel access token (JWT). This does NOT validate the token /// signature or any claims. /// /// The parsed token properties, or null if the token is an invalid format. /// /// Applications generally should not attempt to interpret or rely on any token properties /// other than , because the service may change or omit those claims /// in the future. Other claims are exposed here only for diagnostic purposes. /// public static TunnelAccessTokenProperties? TryParse(string token) { Requires.NotNullOrEmpty(token, nameof(token)); // JWTs are encoded in 3 parts: header, body, and signature. var tokenParts = token.Split('.'); if (tokenParts.Length != 3) { return null; } var tokenBodyJson = Base64UrlDecode(tokenParts[1]); if (tokenBodyJson == null) { return null; } try { var tokenElement = JsonSerializer.Deserialize(tokenBodyJson)!; var tokenProperties = new TunnelAccessTokenProperties(); if (tokenElement.TryGetProperty(ClusterIdClaimName, out var clusterIdElement)) { tokenProperties.ClusterId = clusterIdElement.GetString(); } if (tokenElement.TryGetProperty(TunnelIdClaimName, out var tunnelIdElement)) { tokenProperties.TunnelId = tunnelIdElement.GetString(); } if (tokenElement.TryGetProperty(TunnelPortClaimName, out var tunnelPortElement)) { // The port claim value may be a single port number or an array of ports. if (tunnelPortElement.ValueKind == JsonValueKind.Array) { var array = new ushort[tunnelPortElement.GetArrayLength()]; for (int i = 0; i < array.Length; i++) { array[i] = tunnelPortElement[i].GetUInt16(); } tokenProperties.TunnelPorts = array; } else if (tunnelPortElement.ValueKind == JsonValueKind.Number) { tokenProperties.TunnelPorts = new[] { tunnelPortElement.GetUInt16() }; } } if (tokenElement.TryGetProperty(ScopeClaimName, out var scopeElement)) { var scopes = scopeElement.GetString(); tokenProperties.Scopes = string.IsNullOrEmpty(scopes) ? null : scopes.Split(' '); } if (tokenElement.TryGetProperty(IssuerClaimName, out var issuerElement)) { tokenProperties.Issuer = issuerElement.GetString(); } if (tokenElement.TryGetProperty(ExpirationClaimName, out var expirationElement) && expirationElement.ValueKind == JsonValueKind.Number) { var exp = expirationElement.GetInt64(); tokenProperties.Expiration = DateTimeOffset.FromUnixTimeSeconds(exp).UtcDateTime; } return tokenProperties; } catch (JsonException) { return null; } } private static string? Base64UrlDecode(string encodedString) { // Convert from base64url encoding to base64 encoding: replace chars and add padding. encodedString = encodedString.Replace('-', '+').Replace('_', '/'); encodedString += new string('=', 3 - ((encodedString.Length - 1) % 4)); try { var bytes = Convert.FromBase64String(encodedString); var result = Encoding.UTF8.GetString(bytes); return result; } catch (FormatException) { return null; } } } dev-tunnels-0.0.25/cs/src/Management/TunnelExtensions.cs000066400000000000000000000231511450757157500232100ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Diagnostics.CodeAnalysis; using Microsoft.DevTunnels.Contracts; namespace Microsoft.DevTunnels.Management; /// /// Extension methods for working with objects. /// public static class TunnelExtensions { /// /// Try to get an access token from for . /// /// /// The tokens are searched in Tunnel.AccessTokens dictionary where each /// key may be either a single scope or space-delimited list of scopes. /// /// The tunnel to get the access token from. /// Access token scope to get the token for. /// If non-null and non-empty token is found, the token value. null if not found. /// /// true if has non-null and non-empty an access token for ; /// false if has no access token for or the token is null or empty. /// /// If or is null. /// If is empty. public static bool TryGetAccessToken(this Tunnel tunnel, string accessTokenScope, [NotNullWhen(true)] out string? accessToken) { Requires.NotNull(tunnel, nameof(tunnel)); Requires.NotNullOrEmpty(accessTokenScope, nameof(accessTokenScope)); if (tunnel.AccessTokens?.Count > 0) { var scope = accessTokenScope.AsSpan(); foreach (var (key, value) in tunnel.AccessTokens) { // Each key may be either a single scope or space-delimited list of scopes. var index = 0; while (index < key?.Length) { var spaceIndex = key.IndexOf(' ', index); if (spaceIndex == -1) { spaceIndex = key.Length; } if (spaceIndex - index == scope.Length && key.AsSpan(index, scope.Length).SequenceEqual(scope)) { if (string.IsNullOrEmpty(value)) { accessToken = null; return false; } accessToken = value; return true; } index = spaceIndex + 1; } } } accessToken = null; return false; } /// /// Try to get an access token from for . /// /// /// The tokens are searched in Tunnel.AccessTokens dictionary where each /// key may be either a single scope or space-delimited list of scopes. /// /// The tunnel to get the access token from. /// Access token scope to get the token for. /// If non-null and non-empty token is found, the token value. null if not found. /// /// true if has non-null and non-empty an access token for ; /// false if has no access token for or the token is null or empty. /// /// If or is null. /// If is empty. public static bool TryGetAccessToken(this TunnelV2 tunnel, string accessTokenScope, [NotNullWhen(true)] out string? accessToken) { Requires.NotNull(tunnel, nameof(tunnel)); Requires.NotNullOrEmpty(accessTokenScope, nameof(accessTokenScope)); if (tunnel.AccessTokens?.Count > 0) { var scope = accessTokenScope.AsSpan(); foreach (var (key, value) in tunnel.AccessTokens) { // Each key may be either a single scope or space-delimited list of scopes. var index = 0; while (index < key?.Length) { var spaceIndex = key.IndexOf(' ', index); if (spaceIndex == -1) { spaceIndex = key.Length; } if (spaceIndex - index == scope.Length && key.AsSpan(index, scope.Length).SequenceEqual(scope)) { if (string.IsNullOrEmpty(value)) { accessToken = null; return false; } accessToken = value; return true; } index = spaceIndex + 1; } } } accessToken = null; return false; } /// /// Try to get a valid access token from for . /// If the token is found and looks like JWT, it's validated for expiration. /// /// /// The tokens are searched in Tunnel.AccessTokens dictionary where each /// key may be either a single scope or space-delimited list of scopes. /// The method only validates token expiration. It doesn't validate if the token is not JWT. It doesn't validate JWT signature or claims. /// /// The tunnel to get the access token from. /// Access token scope to get the token for. /// If the token is found and it's valid, the token value. null if not found. /// /// true if has a valid token for ; /// false if has no access token for or the token is null or empty. /// /// If or is null. /// If is empty. /// If the token for is expired. public static bool TryGetValidAccessToken(this Tunnel tunnel, string accessTokenScope, [NotNullWhen(true)] out string? accessToken) { Requires.NotNull(tunnel, nameof(tunnel)); Requires.NotNullOrEmpty(accessTokenScope, nameof(accessTokenScope)); accessToken = null; if (tunnel.TryGetAccessToken(accessTokenScope, out var result)) { TunnelAccessTokenProperties.ValidateTokenExpiration(result); accessToken = result; return true; } return false; } /// /// Try to get a valid access token from for . /// If the token is found and looks like JWT, it's validated for expiration. /// /// /// The tokens are searched in Tunnel.AccessTokens dictionary where each /// key may be either a single scope or space-delimited list of scopes. /// The method only validates token expiration. It doesn't validate if the token is not JWT. It doesn't validate JWT signature or claims. /// /// The tunnel to get the access token from. /// Access token scope to get the token for. /// If the token is found and it's valid, the token value. null if not found. /// /// true if has a valid token for ; /// false if has no access token for or the token is null or empty. /// /// If or is null. /// If is empty. /// If the token for is expired. public static bool TryGetValidAccessToken(this TunnelV2 tunnel, string accessTokenScope, [NotNullWhen(true)] out string? accessToken) { Requires.NotNull(tunnel, nameof(tunnel)); Requires.NotNullOrEmpty(accessTokenScope, nameof(accessTokenScope)); accessToken = null; if (tunnel.TryGetAccessToken(accessTokenScope, out var result)) { TunnelAccessTokenProperties.ValidateTokenExpiration(result); accessToken = result; return true; } return false; } } dev-tunnels-0.0.25/cs/src/Management/TunnelManagementClient.cs000066400000000000000000001752051450757157500242740ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; #if NET5_0_OR_GREATER using System.Net.Http.Json; #endif using System.Threading; using System.Threading.Tasks; using System.Web; using Microsoft.DevTunnels.Contracts; using static Microsoft.DevTunnels.Contracts.TunnelContracts; namespace Microsoft.DevTunnels.Management { /// /// Implementation of a client that manages tunnels and tunnel ports via the tunnel service /// management API. /// public class TunnelManagementClient : ITunnelManagementClient { private const string ApiV1Path = "/api/v1"; private const string TunnelsV1ApiPath = ApiV1Path + "/tunnels"; private const string SubjectsV1ApiPath = ApiV1Path + "/subjects"; private const string UserLimitsV1ApiPath = ApiV1Path + "/userlimits"; private const string TunnelsApiPath = "/tunnels"; private const string SubjectsApiPath = "/subjects"; private const string UserLimitsApiPath = "/userlimits"; private const string EndpointsApiSubPath = "/endpoints"; private const string PortsApiSubPath = "/ports"; private const string ClustersApiPath = "/clusters"; private const string ClustersV1ApiPath = ApiV1Path + "/clusters"; private const string TunnelAuthenticationScheme = "Tunnel"; private const string RequestIdHeaderName = "VsSaaS-Request-Id"; private const string CheckAvailableSubPath = "/checkNameAvailability"; private static readonly string[] ManageAccessTokenScope = new[] { TunnelAccessScopes.Manage }; private static readonly string[] HostAccessTokenScope = new[] { TunnelAccessScopes.Host }; private static readonly string[] ManagePortsAccessTokenScopes = new[] { TunnelAccessScopes.Manage, TunnelAccessScopes.ManagePorts, TunnelAccessScopes.Host, }; private static readonly string[] ReadAccessTokenScopes = new[] { TunnelAccessScopes.Manage, TunnelAccessScopes.ManagePorts, TunnelAccessScopes.Host, TunnelAccessScopes.Connect, }; /// /// Accepted management client api versions /// public string[] TunnelsApiVersions = { "2023-05-23-preview" }; private static readonly ProductInfoHeaderValue TunnelSdkUserAgent = TunnelUserAgent.GetUserAgent(typeof(TunnelManagementClient).Assembly, "Dev-Tunnels-Service-CSharp-SDK")!; private readonly HttpClient httpClient; private readonly Func> userTokenCallback; /// /// Initializes a new instance of the class /// with an optional client authentication callback. /// /// User agent. /// Optional async callback for retrieving a client /// authentication header, for AAD or GitHub user authentication. This may be null /// for anonymous tunnel clients, or if tunnel access tokens will be specified via /// . /// Api version to use for tunnels requests, accepted /// values are public TunnelManagementClient( ProductInfoHeaderValue userAgent, Func>? userTokenCallback = null, string? apiVersion = null) : this(new[] { userAgent }, userTokenCallback, tunnelServiceUri: null, httpHandler: null, apiVersion) { } /// /// Initializes a new instance of the class /// with an optional client authentication callback. /// /// User agent. Muiltiple user agents can be supplied in the /// case that this SDK is used in a program, such as a CLI, that has users that want /// to be differentiated. /// Optional async callback for retrieving a client /// authentication header, for AAD or GitHub user authentication. This may be null /// for anonymous tunnel clients, or if tunnel access tokens will be specified via /// . /// Api version to use for tunnels requests, accepted /// values are public TunnelManagementClient( ProductInfoHeaderValue[] userAgents, Func>? userTokenCallback = null, string? apiVersion = null) : this(userAgents, userTokenCallback, tunnelServiceUri: null, httpHandler: null, apiVersion) { } /// /// Initializes a new instance of the class /// with a client authentication callback, service URI, and HTTP handler. /// /// User agent. /// Optional async callback for retrieving a client /// authentication header value with access token, for AAD or GitHub user authentication. /// This may be null for anonymous tunnel clients, or if tunnel access tokens will be /// specified via . /// Optional tunnel service URI (not including any path), /// or null to use the default global service URI. /// Optional HTTP handler or handler chain that will be invoked /// for HTTPS requests to the tunnel service. The or /// specified (or at the end of the chain) must have /// automatic redirection disabled. The provided HTTP handler will not be disposed /// by . /// Api version to use for tunnels requests, accepted /// values are public TunnelManagementClient( ProductInfoHeaderValue userAgent, Func>? userTokenCallback = null, Uri? tunnelServiceUri = null, HttpMessageHandler? httpHandler = null, string? apiVersion = null) : this(new[] { userAgent }, userTokenCallback, tunnelServiceUri, httpHandler, apiVersion) { } /// /// Initializes a new instance of the class /// with a client authentication callback, service URI, and HTTP handler. /// /// User agent. Muiltiple user agents can be supplied in the /// case that this SDK is used in a program, such as a CLI, that has users that want /// to be differentiated. /// Optional async callback for retrieving a client /// authentication header value with access token, for AAD or GitHub user authentication. /// This may be null for anonymous tunnel clients, or if tunnel access tokens will be /// specified via . /// Optional tunnel service URI (not including any path), /// or null to use the default global service URI. /// Optional HTTP handler or handler chain that will be invoked /// for HTTPS requests to the tunnel service. The or /// specified (or at the end of the chain) must have /// automatic redirection disabled. The provided HTTP handler will not be disposed /// by . /// Api version to use for tunnels requests, accepted /// values are public TunnelManagementClient( ProductInfoHeaderValue[] userAgents, Func>? userTokenCallback = null, Uri? tunnelServiceUri = null, HttpMessageHandler? httpHandler = null, string? apiVersion = null) { Requires.NotNullEmptyOrNullElements(userAgents, nameof(userAgents)); UserAgents = Requires.NotNull(userAgents, nameof(userAgents)); if (!string.IsNullOrEmpty(apiVersion) && !TunnelsApiVersions.Contains(apiVersion)) { throw new ArgumentException( $"Invalid apiVersion, accpeted values are {string.Join(", ", TunnelsApiVersions)} "); } ApiVersion = apiVersion; this.userTokenCallback = userTokenCallback ?? (() => Task.FromResult(null)); httpHandler ??= new SocketsHttpHandler { AllowAutoRedirect = false, }; ValidateHttpHandler(httpHandler); tunnelServiceUri ??= new Uri(TunnelServiceProperties.Production.ServiceUri); if (!tunnelServiceUri.IsAbsoluteUri || tunnelServiceUri.PathAndQuery != "/") { throw new ArgumentException( $"Invalid tunnel service URI: {tunnelServiceUri}", nameof(tunnelServiceUri)); } // The `SocketsHttpHandler` or `HttpClientHandler` automatic redirection is disabled // because they do not keep the Authorization header when redirecting. This handler // will keep all headers when redirecting, and also supports switching the behavior // per-request. httpHandler = new FollowRedirectsHttpHandler(httpHandler); this.httpClient = new HttpClient(httpHandler, disposeHandler: false) { BaseAddress = tunnelServiceUri, }; } private static void ValidateHttpHandler(HttpMessageHandler httpHandler) { while (httpHandler is DelegatingHandler delegatingHandler) { httpHandler = delegatingHandler.InnerHandler!; } if (httpHandler is SocketsHttpHandler socketsHandler) { if (socketsHandler.AllowAutoRedirect) { throw new ArgumentException( "Tunnel client HTTP handler must have automatic redirection disabled.", nameof(httpHandler)); } } else if (httpHandler is HttpClientHandler httpClientHandler) { if (httpClientHandler.AllowAutoRedirect) { throw new ArgumentException( "Tunnel client HTTP handler must have automatic redirection disabled.", nameof(httpHandler)); } else if (httpClientHandler.UseDefaultCredentials) { throw new ArgumentException( "Tunnel client HTTP handler must not use default credentials.", nameof(httpHandler)); } } else { throw new NotSupportedException( $"Unsupported HTTP handler type: {httpHandler?.GetType().Name}. " + "HTTP handler chain must consist of 0 or more DelegatingHandlers " + "ending with a HttpClientHandler."); } } /// /// Gets or sets additional headers that are added to every request. /// public IEnumerable>? AdditionalRequestHeaders { get; set; } private ProductInfoHeaderValue[] UserAgents { get; } private string? ApiVersion { get; } private string TunnelsPath { get { return string.IsNullOrEmpty(ApiVersion) ? TunnelsV1ApiPath : TunnelsApiPath; } } private string ClustersPath { get { return string.IsNullOrEmpty(ApiVersion) ? ClustersV1ApiPath : ClustersApiPath; } } private string SubjectsPath { get { return string.IsNullOrEmpty(ApiVersion) ? SubjectsV1ApiPath : SubjectsApiPath; } } private string UserLimitsPath { get { return string.IsNullOrEmpty(ApiVersion) ? UserLimitsV1ApiPath : UserLimitsApiPath; } } /// /// Sends an HTTP request to the tunnel management API, targeting a specific tunnel. /// /// HTTP request method. /// Tunnel that the request is targeting. /// Required list of access scopes for tokens in /// that could be used to /// authorize the request. /// Optional request sub-path relative to the tunnel. /// Optional query string to append to the request. /// Request options. /// Cancellation token. /// The expected result type. /// Result of the request. /// The request parameters were invalid. /// The request was unauthorized or forbidden. /// The WWW-Authenticate response header may be captured in the exception data. /// The request would have caused a conflict /// or exceeded a limit. /// The request failed for some other /// reason. /// /// This protected method enables subclasses to support additional tunnel management APIs. /// Authentication will use one of the following, if available, in order of preference: /// - on /// - token provided by the user token callback /// - token in that matches /// one of the scopes in /// protected Task SendTunnelRequestAsync( HttpMethod method, Tunnel tunnel, string[] accessTokenScopes, string? path, string? query, TunnelRequestOptions? options, CancellationToken cancellation) { return SendTunnelRequestAsync( method, tunnel, accessTokenScopes, path, query, options, body: null, cancellation); } /// /// Sends an HTTP request with body content to the tunnel management API, targeting a /// specific tunnel. /// /// HTTP request method. /// Tunnel that the request is targeting. /// Required list of access scopes for tokens in /// that could be used to /// authorize the request. /// Optional request sub-path relative to the tunnel. /// Optional query string to append to the request. /// Request options. /// Request body object. /// Cancellation token. /// The request body type. /// The expected result type. /// Result of the request. /// The request parameters were invalid. /// The request was unauthorized or forbidden. /// The WWW-Authenticate response header may be captured in the exception data. /// The request would have caused a conflict /// or exceeded a limit. /// The request failed for some other /// reason. /// /// This protected method enables subclasses to support additional tunnel management APIs. /// Authentication will use one of the following, if available, in order of preference: /// - on /// - token provided by the user token callback /// - token in that matches /// one of the scopes in /// protected async Task SendTunnelRequestAsync( HttpMethod method, Tunnel tunnel, string[] accessTokenScopes, string? path, string? query, TunnelRequestOptions? options, TRequest? body, CancellationToken cancellation) where TRequest : class { var uri = BuildTunnelUri(tunnel, path, query, options); var authHeader = await GetAuthenticationHeaderAsync(tunnel, accessTokenScopes, options); return await SendRequestAsync( method, uri, options, authHeader, body, cancellation); } /// /// Sends an HTTP request with body content to the tunnel management API, targeting a /// specific tunnel. /// /// HTTP request method. /// Tunnel that the request is targeting. /// Required list of access scopes for tokens in /// that could be used to /// authorize the request. /// Optional request sub-path relative to the tunnel. /// Optional query string to append to the request. /// Request options. /// Request body object. /// Cancellation token. /// The request body type. /// The expected result type. /// Result of the request. /// The request parameters were invalid. /// The request was unauthorized or forbidden. /// The WWW-Authenticate response header may be captured in the exception data. /// The request would have caused a conflict /// or exceeded a limit. /// The request failed for some other /// reason. /// /// This protected method enables subclasses to support additional tunnel management APIs. /// Authentication will use one of the following, if available, in order of preference: /// - on /// - token provided by the user token callback /// - token in that matches /// one of the scopes in /// protected async Task SendTunnelRequestAsync( HttpMethod method, TunnelV2 tunnel, string[] accessTokenScopes, string? path, string? query, TunnelRequestOptions? options, TRequest? body, CancellationToken cancellation) where TRequest : class { var uri = BuildTunnelUri(tunnel, path, query, options); var authHeader = await GetAuthenticationHeaderAsync(tunnel, accessTokenScopes, options); return await SendRequestAsync( method, uri, options, authHeader, body, cancellation); } /// /// Sends an HTTP request to the tunnel management API. /// /// HTTP request method. /// Optional tunnel service cluster ID to direct the request to. /// If unspecified, the request will use the global traffic-manager to find the nearest /// cluster. /// Required request path. /// Optional query string to append to the request. /// Request options. /// Cancellation token. /// The expected result type. /// Result of the request. /// The request parameters were invalid. /// The request was unauthorized or forbidden. /// The WWW-Authenticate response header may be captured in the exception data. /// The request would have caused a conflict /// or exceeded a limit. /// The request failed for some other /// reason. /// /// This protected method enables subclasses to support additional tunnel management APIs. /// Authentication will use one of the following, if available, in order of preference: /// - on /// - token provided by the user token callback /// protected Task SendRequestAsync( HttpMethod method, string? clusterId, string path, string? query, TunnelRequestOptions? options, CancellationToken cancellation) { return SendRequestAsync( method, clusterId, path, query, options, body: null, cancellation); } /// /// Sends an HTTP request with body content to the tunnel management API. /// /// HTTP request method. /// Optional tunnel service cluster ID to direct the request to. /// If unspecified, the request will use the global traffic-manager to find the nearest /// cluster. /// Required request path. /// Optional query string to append to the request. /// Request options. /// Request body object. /// Cancellation token. /// The request body type. /// The expected result type. /// Result of the request. /// The request parameters were invalid. /// The request was unauthorized or forbidden. /// The WWW-Authenticate response header may be captured in the exception data. /// The request would have caused a conflict /// or exceeded a limit. /// The request failed for some other /// reason. /// /// This protected method enables subclasses to support additional tunnel management APIs. /// Authentication will use one of the following, if available, in order of preference: /// - on /// - token provided by the user token callback /// protected async Task SendRequestAsync( HttpMethod method, string? clusterId, string path, string? query, TunnelRequestOptions? options, TRequest? body, CancellationToken cancellation) where TRequest : class { var uri = BuildUri(clusterId, path, query, options); Tunnel? tunnel = null; var authHeader = await GetAuthenticationHeaderAsync( tunnel: tunnel, accessTokenScopes: null, options); return await SendRequestAsync( method, uri, options, authHeader, body, cancellation); } /// /// Sends an HTTP request with body content to the tunnel management API, with an /// explicit authentication header value. /// private async Task SendRequestAsync( HttpMethod method, Uri uri, TunnelRequestOptions? options, AuthenticationHeaderValue? authHeader, TRequest? body, CancellationToken cancellation) where TRequest : class { if (authHeader?.Scheme == TunnelAuthenticationSchemes.TunnelPlan) { var token = TunnelPlanTokenProperties.TryParse(authHeader.Parameter ?? string.Empty); if (!string.IsNullOrEmpty(token?.ClusterId)) { var uriStr = uri.ToString().Replace("global.", $"{token.ClusterId}."); uri = new Uri(uriStr); } } var request = new HttpRequestMessage(method, uri); request.Headers.Authorization = authHeader; var emptyHeadersList = Enumerable.Empty>(); var additionalHeaders = (AdditionalRequestHeaders ?? emptyHeadersList).Concat( options?.AdditionalHeaders ?? emptyHeadersList); foreach (var headerNameAndValue in additionalHeaders) { request.Headers.Add(headerNameAndValue.Key, headerNameAndValue.Value); } foreach (ProductInfoHeaderValue userAgent in UserAgents) { request.Headers.UserAgent.Add(userAgent); } request.Headers.UserAgent.Add(TunnelSdkUserAgent); if (body != null) { request.Content = JsonContent.Create(body, null, JsonOptions); } if (options?.FollowRedirects == false) { FollowRedirectsHttpHandler.SetFollowRedirectsEnabledForRequest(request, false); } options?.SetRequestOptions(request); var response = await this.httpClient.SendAsync(request, cancellation); var result = await ConvertResponseAsync( method, response, cancellation); return result; } /// /// Converts a tunnel service HTTP response to a result object (or exception). /// /// Type of result expected, or bool to just check for either success or /// not-found. /// Request method. /// Response from a tunnel service request. /// Cancellation token. /// Result object of the requested type, or false if the response was 404 and /// the result type is boolean, or null if a GET request for a non-array result object type /// returned 404 Not Found. /// The service returned a /// 400 Bad Request response. /// The service returned a 401 Unauthorized /// or 403 Forbidden response. private static async Task ConvertResponseAsync( HttpMethod method, HttpResponseMessage response, CancellationToken cancellation) { Requires.NotNull(response, nameof(response)); // Requests that expect a boolean result just check for success or not-found result. // GET requests that expect a single object result return null for not found result. // GET requests that expect an array result should throw an error for not-found result // because empty array was expected instead. // PUT/POST/PATCH requests should also throw an error for not-found. bool allowNotFound = typeof(T) == typeof(bool) || ((method == HttpMethod.Get || method == HttpMethod.Head) && !typeof(T).IsArray); string? errorMessage = null; Exception? innerException = null; if (response.IsSuccessStatusCode) { if (response.StatusCode == HttpStatusCode.NoContent || response.Content == null) { return typeof(T) == typeof(bool) ? (T?)(object)(bool?)true : default; } try { T? result = await response.Content.ReadFromJsonAsync( JsonOptions, cancellation); return result; } catch (Exception ex) { innerException = ex; errorMessage = "Tunnel service response deserialization error: " + ex.Message; } } if (errorMessage == null && response.Content != null) { try { if ((int)response.StatusCode >= 400 && (int)response.StatusCode < 500) { // 4xx status responses may include standard ProblemDetails. var problemDetails = await response.Content .ReadFromJsonAsync(JsonOptions, cancellation); if (!string.IsNullOrEmpty(problemDetails?.Title) || !string.IsNullOrEmpty(problemDetails?.Detail)) { if (allowNotFound && response.StatusCode == HttpStatusCode.NotFound && problemDetails.Detail == null) { return default; } errorMessage = "Tunnel service error: " + problemDetails!.Title + " " + problemDetails.Detail; if (problemDetails.Errors != null) { foreach (var error in problemDetails.Errors) { var messages = string.Join(" ", error.Value); errorMessage += $"\n{error.Key}: {messages}"; } } } } else if ((int)response.StatusCode >= 500) { // 5xx status responses may include VS SaaS error details. var errorDetails = await response.Content.ReadFromJsonAsync( JsonOptions, cancellation); if (!string.IsNullOrEmpty(errorDetails?.Message)) { errorMessage = "Tunnel service error: " + errorDetails!.Message; if (!string.IsNullOrEmpty(errorDetails.StackTrace)) { errorMessage += "\n" + errorDetails.StackTrace; } } } } catch (Exception ex) { // A default error message will be filled in below. innerException = ex; } } errorMessage ??= "Tunnel service response status code: " + response.StatusCode; if (response.Headers.TryGetValues(RequestIdHeaderName, out var requestId)) { errorMessage += $"\nRequest ID: {requestId.First()}"; } try { response.EnsureSuccessStatusCode(); } catch (HttpRequestException hrex) { switch (response.StatusCode) { case HttpStatusCode.BadRequest: throw new ArgumentException(errorMessage, hrex); case HttpStatusCode.Unauthorized: case HttpStatusCode.Forbidden: var ex = new UnauthorizedAccessException(errorMessage, hrex); // The HttpResponseHeaders.WwwAuthenticate property does not correctly // handle multiple values! Get the values by name instead. if (response.Headers.TryGetValues( "WWW-Authenticate", out var authHeaderValues)) { ex.SetAuthenticationSchemes(authHeaderValues); } throw ex; case HttpStatusCode.NotFound: case HttpStatusCode.Conflict: case HttpStatusCode.TooManyRequests: throw new InvalidOperationException(errorMessage, hrex); case HttpStatusCode.Redirect: case HttpStatusCode.RedirectKeepVerb: // Add the redirect location to the exception data. // Normally the HTTP client should automatically follow redirects, // but this allows tests to validate the service's redirection behavior // when client auto redirection is disabled. hrex.Data["Location"] = response.Headers.Location; throw; default: throw; } } throw new Exception(errorMessage, innerException); } /// /// Error details that may be returned from the service with 500 status responses /// (when in development mode). /// /// /// Copied from Microsoft.VsSaaS.Common to avoid taking a dependency on that assembly. /// private class ErrorDetails { public string? Message { get; set; } public string? StackTrace { get; set; } } /// public void Dispose() { this.httpClient.Dispose(); } private Uri BuildUri( string? clusterId, string path, string? query, TunnelRequestOptions? options) { Requires.NotNullOrEmpty(path, nameof(path)); var baseAddress = this.httpClient.BaseAddress!; var builder = new UriBuilder(baseAddress); if (!string.IsNullOrEmpty(clusterId) && baseAddress.HostNameType == UriHostNameType.Dns) { if (baseAddress.Host != "localhost" && !baseAddress.Host.StartsWith($"{clusterId}.")) { // A specific cluster ID was specified (while not running on localhost). // Prepend the cluster ID to the hostname, and optionally strip a global prefix. builder.Host = $"{clusterId}.{builder.Host}".Replace("global.", string.Empty); } else if (baseAddress.Scheme == "https" && clusterId.StartsWith("localhost") && builder.Port % 10 > 0 && ushort.TryParse(clusterId.Substring("localhost".Length), out var clusterNumber)) { // Local testing simulates clusters by running the service on multiple ports. // Change the port number to match the cluster ID suffix. if (clusterNumber > 0 && clusterNumber < 10) { builder.Port = builder.Port - (builder.Port % 10) + clusterNumber; } } } if (options != null) { var optionsQuery = options.ToQueryString(); if (!string.IsNullOrEmpty(optionsQuery)) { query = optionsQuery + (!string.IsNullOrEmpty(query) ? '&' + query : string.Empty); } } builder.Path = path; builder.Query = query; return builder.Uri; } private Uri BuildTunnelUri( Tunnel tunnel, string? path, string? query, TunnelRequestOptions? options) { Requires.NotNull(tunnel, nameof(tunnel)); string tunnelPath; var pathBase = TunnelsPath; if (!string.IsNullOrEmpty(tunnel.ClusterId) && !string.IsNullOrEmpty(tunnel.TunnelId)) { tunnelPath = $"{pathBase}/{tunnel.TunnelId}"; } else { Requires.Argument( !string.IsNullOrEmpty(tunnel.Name), nameof(tunnel), "Tunnel object must include either a name or tunnel ID and cluster ID."); if (string.IsNullOrEmpty(tunnel.Domain)) { tunnelPath = $"{pathBase}/{tunnel.Name}"; } else { // Append the domain to the tunnel name. tunnelPath = $"{pathBase}/{tunnel.Name}.{tunnel.Domain}"; } } return BuildUri( tunnel.ClusterId, tunnelPath + (!string.IsNullOrEmpty(path) ? path : string.Empty), query, options); } private Uri BuildTunnelUri( TunnelV2 tunnel, string? path, string? query, TunnelRequestOptions? options) { Requires.NotNull(tunnel, nameof(tunnel)); string tunnelPath; var pathBase = TunnelsPath; if (!string.IsNullOrEmpty(tunnel.ClusterId) && !string.IsNullOrEmpty(tunnel.TunnelId)) { tunnelPath = $"{pathBase}/{tunnel.TunnelId}"; } else { Requires.Argument( !string.IsNullOrEmpty(tunnel.Name), nameof(tunnel), "Tunnel object must include either a name or tunnel ID and cluster ID."); if (string.IsNullOrEmpty(tunnel.Domain)) { tunnelPath = $"{pathBase}/{tunnel.Name}"; } else { // Append the domain to the tunnel name. tunnelPath = $"{pathBase}/{tunnel.Name}.{tunnel.Domain}"; } } return BuildUri( tunnel.ClusterId, tunnelPath + (!string.IsNullOrEmpty(path) ? path : string.Empty), query, options); } private async Task GetAuthenticationHeaderAsync( Tunnel? tunnel, string[]? accessTokenScopes, TunnelRequestOptions? options) { AuthenticationHeaderValue? authHeader = null; if (!string.IsNullOrEmpty(options?.AccessToken)) { TunnelAccessTokenProperties.ValidateTokenExpiration(options.AccessToken); authHeader = new AuthenticationHeaderValue( TunnelAuthenticationScheme, options.AccessToken); } if (authHeader == null) { authHeader = await this.userTokenCallback(); } if (authHeader == null && tunnel?.AccessTokens != null && accessTokenScopes != null) { foreach (var scope in accessTokenScopes) { if (tunnel.TryGetValidAccessToken(scope, out string? accessToken)) { authHeader = new AuthenticationHeaderValue( TunnelAuthenticationScheme, accessToken); break; } } } return authHeader; } private async Task GetAuthenticationHeaderAsync( TunnelV2? tunnel, string[]? accessTokenScopes, TunnelRequestOptions? options) { AuthenticationHeaderValue? authHeader = null; if (!string.IsNullOrEmpty(options?.AccessToken)) { TunnelAccessTokenProperties.ValidateTokenExpiration(options.AccessToken); authHeader = new AuthenticationHeaderValue( TunnelAuthenticationScheme, options.AccessToken); } if (authHeader == null) { authHeader = await this.userTokenCallback(); } if (authHeader == null && tunnel?.AccessTokens != null && accessTokenScopes != null) { foreach (var scope in accessTokenScopes) { if (tunnel.TryGetValidAccessToken(scope, out string? accessToken)) { authHeader = new AuthenticationHeaderValue( TunnelAuthenticationScheme, accessToken); break; } } } return authHeader; } /// public async Task ListTunnelsAsync( string? clusterId, string? domain, TunnelRequestOptions? options, bool? ownedTunnelsOnly, CancellationToken cancellation) { var queryParams = new string?[] { string.IsNullOrEmpty(clusterId) ? "global=true" : null, !string.IsNullOrEmpty(domain) ? $"domain={HttpUtility.UrlEncode(domain)}" : null, !string.IsNullOrEmpty(ApiVersion) ? GetApiQuery() : null, ownedTunnelsOnly == true ? "ownedTunnelsOnly=true" : null, }; var query = string.Join("&", queryParams.Where((p) => p != null)); var result = await this.SendRequestAsync( HttpMethod.Get, clusterId, TunnelsPath, query, options, cancellation); return result!; } /// [Obsolete("Use ListTunnelsAsync() method with TunnelRequestOptions.Tags instead.")] public async Task SearchTunnelsAsync( string[] tags, bool requireAllTags, string? clusterId, string? domain, TunnelRequestOptions? options, CancellationToken cancellation) { var queryParams = new string?[] { string.IsNullOrEmpty(clusterId) ? "global=true" : null, !string.IsNullOrEmpty(domain) ? $"domain={HttpUtility.UrlEncode(domain)}" : null, $"tags={string.Join(",", tags.Select(HttpUtility.UrlEncode))}", $"allTags={requireAllTags}", !string.IsNullOrEmpty(ApiVersion) ? GetApiQuery() : null, }; var query = string.Join("&", queryParams.Where((p) => p != null)); var result = await this.SendRequestAsync( HttpMethod.Get, clusterId, TunnelsPath, query, options, cancellation); return result!; } /// public async Task GetTunnelAsync( Tunnel tunnel, TunnelRequestOptions? options, CancellationToken cancellation) { var result = await this.SendTunnelRequestAsync( HttpMethod.Get, tunnel, ReadAccessTokenScopes, path: null, query: GetApiQuery(), options, cancellation); PreserveAccessTokens(tunnel, result); return result; } /// public async Task CreateTunnelAsync( Tunnel tunnel, TunnelRequestOptions? options, CancellationToken cancellation) { Requires.NotNull(tunnel, nameof(tunnel)); var tunnelId = tunnel.TunnelId; if (tunnelId != null) { throw new ArgumentException( "An ID may not be specified when creating a tunnel.", nameof(tunnelId)); } var result = await this.SendRequestAsync( HttpMethod.Post, tunnel.ClusterId, TunnelsPath, query: GetApiQuery(), options, ConvertTunnelForRequest(tunnel), cancellation); PreserveAccessTokens(tunnel, result); return result!; } /// public async Task UpdateTunnelAsync( Tunnel tunnel, TunnelRequestOptions? options, CancellationToken cancellation) { var result = await this.SendTunnelRequestAsync( HttpMethod.Put, tunnel, ManageAccessTokenScope, path: null, query: GetApiQuery(), options, ConvertTunnelForRequest(tunnel), cancellation); PreserveAccessTokens(tunnel, result); return result!; } /// public async Task DeleteTunnelAsync( Tunnel tunnel, TunnelRequestOptions? options, CancellationToken cancellation) { var result = await this.SendTunnelRequestAsync( HttpMethod.Delete, tunnel, ManageAccessTokenScope, path: null, query: GetApiQuery(), options, cancellation); return result; } /// public async Task UpdateTunnelEndpointAsync( Tunnel tunnel, TunnelEndpoint endpoint, TunnelRequestOptions? options = null, CancellationToken cancellation = default) { Requires.NotNull(endpoint, nameof(endpoint)); Requires.NotNullOrEmpty(endpoint.HostId!, nameof(TunnelEndpoint.HostId)); var path = $"{EndpointsApiSubPath}/{endpoint.HostId}/{endpoint.ConnectionMode}"; var result = (await this.SendTunnelRequestAsync( HttpMethod.Put, tunnel, HostAccessTokenScope, path, query: GetApiQuery(), options, endpoint, cancellation))!; if (tunnel.Endpoints != null) { // Also update the endpoint in the local tunnel object. tunnel.Endpoints = tunnel.Endpoints .Where((e) => e.HostId != endpoint.HostId || e.ConnectionMode != endpoint.ConnectionMode) .Append(result) .ToArray(); } return result; } /// public async Task DeleteTunnelEndpointsAsync( Tunnel tunnel, string hostId, TunnelConnectionMode? connectionMode, TunnelRequestOptions? options = null, CancellationToken cancellation = default) { Requires.NotNullOrEmpty(hostId, nameof(hostId)); var path = connectionMode == null ? $"{EndpointsApiSubPath}/{hostId}" : $"{EndpointsApiSubPath}/{hostId}/{connectionMode}"; var result = await this.SendTunnelRequestAsync( HttpMethod.Delete, tunnel, HostAccessTokenScope, path, query: GetApiQuery(), options, cancellation); if (result && tunnel.Endpoints != null) { // Also delete the endpoint in the local tunnel object. tunnel.Endpoints = tunnel.Endpoints .Where((e) => e.HostId != hostId || e.ConnectionMode != connectionMode) .ToArray(); } return result; } /// public async Task ListTunnelPortsAsync( Tunnel tunnel, TunnelRequestOptions? options, CancellationToken cancellation) { var result = await this.SendTunnelRequestAsync( HttpMethod.Get, tunnel, ReadAccessTokenScopes, PortsApiSubPath, query: GetApiQuery(), options, cancellation); return result!; } /// public async Task GetTunnelPortAsync( Tunnel tunnel, ushort portNumber, TunnelRequestOptions? options, CancellationToken cancellation) { var path = $"{PortsApiSubPath}/{portNumber}"; var result = await this.SendTunnelRequestAsync( HttpMethod.Get, tunnel, ReadAccessTokenScopes, path, query: GetApiQuery(), options, cancellation); return result; } /// public async Task CreateTunnelPortAsync( Tunnel tunnel, TunnelPort tunnelPort, TunnelRequestOptions? options, CancellationToken cancellation) { Requires.NotNull(tunnelPort, nameof(tunnelPort)); var result = (await this.SendTunnelRequestAsync( HttpMethod.Post, tunnel, ManagePortsAccessTokenScopes, PortsApiSubPath, query: GetApiQuery(), options, ConvertTunnelPortForRequest(tunnel, tunnelPort), cancellation))!; PreserveAccessTokens(tunnelPort, result); if (tunnel.Ports != null) { // Also add the port to the local tunnel object. tunnel.Ports = tunnel.Ports .Where((p) => p.PortNumber != tunnelPort.PortNumber) .Append(result) .OrderBy((p) => p.PortNumber) .ToArray(); } return result; } /// public async Task UpdateTunnelPortAsync( Tunnel tunnel, TunnelPort tunnelPort, TunnelRequestOptions? options, CancellationToken cancellation) { Requires.NotNull(tunnelPort, nameof(tunnelPort)); if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && tunnelPort.ClusterId != tunnel.ClusterId) { throw new ArgumentException( "Tunnel port cluster ID is not consistent.", nameof(tunnelPort)); } var portNumber = tunnelPort.PortNumber; var path = $"{PortsApiSubPath}/{portNumber}"; var result = (await this.SendTunnelRequestAsync( HttpMethod.Put, tunnel, ManagePortsAccessTokenScopes, path, query: GetApiQuery(), options, ConvertTunnelPortForRequest(tunnel, tunnelPort), cancellation))!; PreserveAccessTokens(tunnelPort, result); if (tunnel.Ports != null) { // Also update the port in the local tunnel object. tunnel.Ports = tunnel.Ports .Where((p) => p.PortNumber != tunnelPort.PortNumber) .Append(result) .OrderBy((p) => p.PortNumber) .ToArray(); } return result; } /// public async Task DeleteTunnelPortAsync( Tunnel tunnel, ushort portNumber, TunnelRequestOptions? options, CancellationToken cancellation) { var path = $"{PortsApiSubPath}/{portNumber}"; var result = await this.SendTunnelRequestAsync( HttpMethod.Delete, tunnel, ManagePortsAccessTokenScopes, path, query: GetApiQuery(), options, cancellation); if (result && tunnel.Ports != null) { // Also delete the port in the local tunnel object. tunnel.Ports = tunnel.Ports .Where((p) => p.PortNumber != portNumber) .OrderBy((p) => p.PortNumber) .ToArray(); } return result; } /// /// Removes read-only properties like tokens and status from create/update requests. /// private Tunnel ConvertTunnelForRequest(Tunnel tunnel) { return new Tunnel { Name = tunnel.Name, Domain = tunnel.Domain, Description = tunnel.Description, Tags = tunnel.Tags, CustomExpiration = tunnel.CustomExpiration, Options = tunnel.Options, AccessControl = tunnel.AccessControl == null ? null : new TunnelAccessControl( tunnel.AccessControl.Where((ace) => !ace.IsInherited)), Endpoints = tunnel.Endpoints, Ports = tunnel.Ports? .Select((p) => ConvertTunnelPortForRequest(tunnel, p)) .ToArray(), }; } /// /// Removes read-only properties like tokens and status from create/update requests. /// private TunnelPort ConvertTunnelPortForRequest(Tunnel tunnel, TunnelPort tunnelPort) { if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && tunnelPort.ClusterId != tunnel.ClusterId) { throw new ArgumentException( "Tunnel port cluster ID does not match tunnel.", nameof(tunnelPort)); } if (tunnelPort.TunnelId != null && tunnel.TunnelId != null && tunnelPort.TunnelId != tunnel.TunnelId) { throw new ArgumentException( "Tunnel port tunnel ID does not match tunnel.", nameof(tunnelPort)); } return new TunnelPort { PortNumber = tunnelPort.PortNumber, Protocol = tunnelPort.Protocol, IsDefault = tunnelPort.IsDefault, Description = tunnelPort.Description, Tags = tunnelPort.Tags, Options = tunnelPort.Options, AccessControl = tunnelPort.AccessControl == null ? null : new TunnelAccessControl( tunnelPort.AccessControl.Where((ace) => !ace.IsInherited)), SshUser = tunnelPort.SshUser, }; } /// public async Task FormatSubjectsAsync( TunnelAccessSubject[] subjects, TunnelRequestOptions? options = null, CancellationToken cancellation = default) { Requires.NotNull(subjects, nameof(subjects)); if (subjects.Length == 0) { return subjects; } var formattedSubjects = await SendRequestAsync ( HttpMethod.Post, clusterId: null, SubjectsPath + "/format", query: GetApiQuery(), options, subjects, cancellation); return formattedSubjects!; } /// public async Task ResolveSubjectsAsync( TunnelAccessSubject[] subjects, TunnelRequestOptions? options = null, CancellationToken cancellation = default) { Requires.NotNull(subjects, nameof(subjects)); if (subjects.Length == 0) { return subjects; } var resolvedSubjects = await SendRequestAsync ( HttpMethod.Post, clusterId: null, SubjectsPath + "/resolve", query: GetApiQuery(), options, subjects, cancellation); return resolvedSubjects!; } /// public async Task ListUserLimitsAsync(CancellationToken cancellation = default) { var userLimits = await SendRequestAsync( HttpMethod.Get, clusterId: null, UserLimitsPath, query: GetApiQuery(), options: null, cancellation); return userLimits!; } /// public async Task ListClustersAsync(CancellationToken cancellation) { var baseAddress = this.httpClient.BaseAddress!; var builder = new UriBuilder(baseAddress); builder.Path = ClustersPath; builder.Query = GetApiQuery(); var clusterDetails = await SendRequestAsync( HttpMethod.Get, builder.Uri, options: null, authHeader: null, body: null, cancellation); return clusterDetails!; } /// public async Task CheckNameAvailabilityAsync( string name, CancellationToken cancellation = default) { name = Uri.EscapeDataString(name); Requires.NotNull(name, nameof(name)); return await this.SendRequestAsync( HttpMethod.Get, clusterId: null, TunnelsPath + "/" + name + CheckAvailableSubPath, query: null, options: null, cancellation ); } /// /// Gets required query string parmeters /// /// Query string protected virtual string? GetApiQuery() { return string.IsNullOrEmpty(ApiVersion) ? null : $"api-version={ApiVersion}"; } /// /// Copy access tokens from the request object to the result object, except for any /// tokens that were refreshed by the request. /// /// /// This intentionally does not check whether any existing tokens are expired. So /// expired tokens may be preserved also, if not refreshed. This allows for better /// diagnostics in that case. /// private static void PreserveAccessTokens(Tunnel requestTunnel, Tunnel? resultTunnel) { if (requestTunnel.AccessTokens != null && resultTunnel != null) { resultTunnel.AccessTokens ??= new Dictionary(); foreach (var scopeAndToken in requestTunnel.AccessTokens) { if (!resultTunnel.AccessTokens.ContainsKey(scopeAndToken.Key)) { resultTunnel.AccessTokens[scopeAndToken.Key] = scopeAndToken.Value; } } } } /// /// Copy access tokens from the request object to the result object, except for any /// tokens that were refreshed by the request. /// private static void PreserveAccessTokens(TunnelPort requestPort, TunnelPort? resultPort) { if (requestPort.AccessTokens != null && resultPort != null) { resultPort.AccessTokens ??= new Dictionary(); foreach (var scopeAndToken in requestPort.AccessTokens) { if (!resultPort.AccessTokens.ContainsKey(scopeAndToken.Key)) { resultPort.AccessTokens[scopeAndToken.Key] = scopeAndToken.Value; } } } } } } dev-tunnels-0.0.25/cs/src/Management/TunnelManagementClientExtensions.cs000066400000000000000000000211341450757157500263430ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.DevTunnels.Contracts; namespace Microsoft.DevTunnels.Management { /// /// Extension methods . /// public static class TunnelManagementClientExtensions { /// /// Looks up and formats subject names for display. /// /// The tunnel management client that is used /// to call the service. /// Access control entries to be formatted. Items in /// must be subject IDs. (For AAD the /// IDs are user or group object ID GUIDs; for GitHub they are user or team ID integers.) /// The subjects are then updated in-place to the formatted subject names. /// Required identity provider of subjects to format. The /// must authenticate using this provider. /// Optional organization of subjects to format. If specified, /// the must authenticate in this organization. /// Cancellation token. /// List of subjects that could not be formatted because the IDs were /// not found, or an empty list if all subjects were formatted successfully. public static async Task> FormatSubjectsAsync( this ITunnelManagementClient managementClient, IEnumerable accessControl, string provider, string? organization, CancellationToken cancellation = default) { Requires.NotNull(managementClient, nameof(managementClient)); Requires.NotNull(accessControl, nameof(accessControl)); Requires.NotNullOrEmpty(provider, nameof(provider)); var subjects = new List(); var aceIndexes = new List<(TunnelAccessControlEntry, int)>(); foreach (var ace in accessControl) { // With AAD, a user can only query info about their current organization. // With GH, a user can be a member of multiple orgs so there is no "current". if (ace.Provider == provider && (ace.Provider != TunnelAccessControlEntry.Providers.Microsoft || ace.Organization == organization || ace.Type == TunnelAccessControlEntryType.Organizations)) { bool isOrganizationIdRequired = ace.Type == TunnelAccessControlEntryType.Groups && ace.Provider == TunnelAccessControlEntry.Providers.GitHub; for (int i = 0; i < ace.Subjects.Length; i++) { subjects.Add(new TunnelAccessSubject { Type = ace.Type, Id = ace.Subjects[i], OrganizationId = isOrganizationIdRequired ? ace.Organization : null, }); aceIndexes.Add((ace, i)); } } } var unformattedSubjects = new List(subjects.Count); if (subjects.Count > 0) { var formattedSubjects = await managementClient.FormatSubjectsAsync( subjects.ToArray(), options: null, cancellation); for (int i = 0; i < formattedSubjects.Length; i++) { var formattedName = formattedSubjects[i].Name; if (!string.IsNullOrEmpty(formattedName)) { var (ace, subjectIndex) = aceIndexes[i]; ace.Subjects[subjectIndex] = formattedName; } else { unformattedSubjects.Add(formattedSubjects[i]); } } } return unformattedSubjects; } /// /// Resolves partial or full subject display names or emails to IDs. /// /// The tunnel management client that is used /// to call the service. /// Access control entries to be resolved. Items in /// must be partial or full subject names. /// (For AAD the subjects are user or group emails or display names; for GitHub they are /// user or team names or display names.) The subjects are then updated in-place to the /// resolved subject IDs. /// Required identity provider of subjects to resolve. The /// must authenticate using this provider. /// Optional organization of subjects to resolve. If specified, /// the must authenticate in this organization. /// Cancellation token. /// List of subjects that could not be resolved or had multiple partial /// matches, or an empty list if all subjects were resolved successfully. public static async Task> ResolveSubjectsAsync( this ITunnelManagementClient managementClient, IEnumerable accessControl, string provider, string? organization, CancellationToken cancellation = default) { Requires.NotNull(managementClient, nameof(managementClient)); Requires.NotNull(accessControl, nameof(accessControl)); Requires.NotNullOrEmpty(provider, nameof(provider)); var subjects = new List(); var aceIndexes = new List<(TunnelAccessControlEntry, int)>(); foreach (var ace in accessControl) { if (ace.Provider == provider && (ace.Organization == organization || ace.Type == TunnelAccessControlEntryType.Organizations)) { for (int i = 0; i < ace.Subjects.Length; i++) { subjects.Add(new TunnelAccessSubject { Type = ace.Type, Name = ace.Subjects[i], }); aceIndexes.Add((ace, i)); } } } var unresolvedSubjects = new List(subjects.Count); if (subjects.Count > 0) { var resolvedSubjects = await managementClient.ResolveSubjectsAsync( subjects.ToArray(), options: null, cancellation); for (int i = 0; i < resolvedSubjects.Length; i++) { var resolvedId = resolvedSubjects[i].Id; var resolvedOrgId = resolvedSubjects[i].OrganizationId; if (!string.IsNullOrEmpty(resolvedId)) { var (ace, subjectIndex) = aceIndexes[i]; ace.Subjects[subjectIndex] = resolvedId; if (!string.IsNullOrEmpty(resolvedOrgId)) { if (ace.Organization == null) { ace.Organization = resolvedOrgId; } else if (ace.Organization != resolvedOrgId) { throw new ArgumentException( "Multiple teams must be in the same organization."); } } } else { unresolvedSubjects.Add(resolvedSubjects[i]); } } } return unresolvedSubjects; } } } dev-tunnels-0.0.25/cs/src/Management/TunnelPlanTokenProperties.cs000066400000000000000000000141171450757157500250230ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // using System; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace Microsoft.DevTunnels.Management; /// /// Supports parsing tunnelPlan token JWT properties to allow for some pre-validation /// and diagnostics. /// public class TunnelPlanTokenProperties { private const string ClusterIdClaimName = "clusterId"; private const string IssuerClaimName = "iss"; private const string ExpirationClaimName = "exp"; private const string UserEmailClaimName = "userEmail"; private const string TunnelPlanIdClaimName = "tunnelPlanId"; private const string SubscriptionIdClaimName = "subscriptionId"; private const string ScopeClaimName = "scp"; /// /// Gets the token cluster ID claim. /// public string? ClusterId { get; private set; } /// /// Gets the subscription ID claim. /// public string? SubscriptionId { get; private set; } /// /// Gets the token user email claim. /// public string? UserEmail { get; private set; } /// /// Gets the token scopes claim. /// public string[]? Scopes { get; private set; } /// /// Gets the token issuer URI. /// public string? Issuer { get; private set; } /// /// Gets the token expiration. /// [JsonIgnore] public DateTime? Expiration { get; private set; } /// /// Gets the token tunnel plan id claim. /// public string? TunnelPlanId { get; private set; } /// /// Checks if the tunnel access token expiration claim is in the past. /// /// The token is expired. /// Note this does not throw if the token is an invalid format. public static void ValidateTokenExpiration(string token) { var t = TryParse(token); if (t?.Expiration <= DateTime.UtcNow) { throw new UnauthorizedAccessException("The access token is expired: " + t); } } /// /// Gets token representation for tracing. /// public static string GetTokenTrace(string? token) { if (token == null) { return ""; } if (token == string.Empty) { return ""; } return TryParse(token) is TunnelPlanTokenProperties t ? $"" : ""; } /// /// Attempts to parse a tunnel access token (JWT). This does NOT validate the token /// signature or any claims. /// /// The parsed token properties, or null if the token is an invalid format. /// /// Applications generally should not attempt to interpret or rely on any token properties /// other than , because the service may change or omit those claims /// in the future. Other claims are exposed here only for diagnostic purposes. /// public static TunnelPlanTokenProperties? TryParse(string token) { Requires.NotNullOrEmpty(token, nameof(token)); // JWTs are encoded in 3 parts: header, body, and signature. var tokenParts = token.Split('.'); if (tokenParts.Length != 3) { return null; } var tokenBodyJson = Base64UrlDecode(tokenParts[1]); if (tokenBodyJson == null) { return null; } try { var tokenElement = JsonSerializer.Deserialize(tokenBodyJson)!; var tokenProperties = new TunnelPlanTokenProperties(); if (tokenElement.TryGetProperty(ClusterIdClaimName, out var clusterIdElement)) { tokenProperties.ClusterId = clusterIdElement.GetString(); } if (tokenElement.TryGetProperty(SubscriptionIdClaimName, out var subscriptionId)) { tokenProperties.SubscriptionId = subscriptionId.GetString(); } if (tokenElement.TryGetProperty(TunnelPlanIdClaimName, out var tunnelPlanId)) { tokenProperties.TunnelPlanId = tunnelPlanId.GetString(); } if (tokenElement.TryGetProperty(UserEmailClaimName, out var userEmail)) { tokenProperties.UserEmail = userEmail.GetString(); } if (tokenElement.TryGetProperty(ScopeClaimName, out var scopeElement)) { var scopes = scopeElement.GetString(); tokenProperties.Scopes = string.IsNullOrEmpty(scopes) ? null : scopes.Split(' '); } if (tokenElement.TryGetProperty(IssuerClaimName, out var issuerElement)) { tokenProperties.Issuer = issuerElement.GetString(); } if (tokenElement.TryGetProperty(ExpirationClaimName, out var expirationElement) && expirationElement.ValueKind == JsonValueKind.Number) { var exp = expirationElement.GetInt64(); tokenProperties.Expiration = DateTimeOffset.FromUnixTimeSeconds(exp).UtcDateTime; } return tokenProperties; } catch (JsonException) { return null; } } private static string? Base64UrlDecode(string encodedString) { // Convert from base64url encoding to base64 encoding: replace chars and add padding. encodedString = encodedString.Replace('-', '+').Replace('_', '/'); encodedString += new string('=', 3 - ((encodedString.Length - 1) % 4)); try { var bytes = Convert.FromBase64String(encodedString); var result = Encoding.UTF8.GetString(bytes); return result; } catch (FormatException) { return null; } } } dev-tunnels-0.0.25/cs/src/Management/TunnelRequestOptions.cs000066400000000000000000000207751450757157500240660ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Web; using Microsoft.DevTunnels.Contracts; namespace Microsoft.DevTunnels.Management { /// /// Options for tunnel service requests. /// /// /// All options are disabled by default. In general, enabling options may result /// in slower queries (because the server has more work to do). /// /// Certain options may only apply to certain kinds of requests. /// public class TunnelRequestOptions { private static readonly string[] TrueOption = new[] { "true" }; /// /// Gets or sets a tunnel access token for the request. /// /// /// Note this should not be a _user_ access token (such as AAD or GitHub); use the /// callback parameter to the constructor to /// supply user access tokens. /// /// When an access token is provided here, it is used instead of any user token from the /// callback. /// public string? AccessToken { get; set; } /// /// Gets or sets additional headers to be included in the request. /// public IEnumerable>? AdditionalHeaders { get; set; } /// /// Gets or sets additional query parameters to be included in the request. /// public IEnumerable>? AdditionalQueryParameters { get; set; } /// /// Gets or sets additional http request options /// for HttpRequestMessage.Options (in net6.0) or /// HttpRequestMessage.Properties (in netcoreapp3.1). /// public IEnumerable>? HttpRequestOptions { get; set; } /// /// Gets or sets a value that indicates whether HTTP redirect responses will be /// automatically followed. /// /// /// The default is true. If false, a redirect response will cause a /// to be thrown, with the redirect target location /// in the exception data. /// /// The tunnel service often redirects requests to the "home" cluster of the requested /// tunnel, when necessary to fulfill the request. /// public bool FollowRedirects { get; set; } = true; /// /// Gets or sets a flag that requests tunnel ports when retrieving tunnels. /// /// /// Ports are excluded by default when retrieving a tunnel or when listing or searching /// tunnels. This option enables including ports for all tunnels returned by a list or /// search query. /// public bool IncludePorts { get; set; } /// /// Gets or sets a flag that requests access control details when retrieving tunnels. /// /// /// Access control details are always included when retrieving a single tunnel, /// but excluded by default when listing or searching tunnels. This option enables /// including access controls for all tunnels returned by a list or search query. /// public bool IncludeAccessControl { get; set; } /// /// Gets or sets an optional list of tags to filter the requested tunnels or ports. /// /// /// Requested tags are compared to the or /// when calling /// or /// respectively. By default, an /// item is included if ANY tag matches; set to match ALL /// tags instead. /// public string[]? Tags { get; set; } /// /// Gets or sets a flag that indicates whether listed items must match all tags /// specified in . If false, an item is included if any tag matches. /// public bool RequireAllTags { get; set; } /// /// Gets or sets an optional list of token scopes that are requested when retrieving /// a tunnel or tunnel port object. /// /// /// Each item in the array must be a single scope from /// or a space-delimited combination of multiple scopes. The service issues an access /// token for each scope or combination and returns the token(s) in the /// or dictionary. /// If the caller does not have permission to get a token for one or more scopes then a /// token is not returned but the overall request does not fail. Token properties including /// scopes and expiration may be checked using . /// public string[]? TokenScopes { get; set; } /// /// If true on a create or update request then upon a name conflict, attempt to rename the /// existing tunnel to null and give the name to the tunnel from the request. /// public bool ForceRename { get; set; } /// /// Limits the number of tunnels returned when searching or listing tunnels. /// public uint? Limit { get; set; } /// /// Converts tunnel request options to a query string for HTTP requests to the /// tunnel management API. /// protected internal virtual string ToQueryString() { var queryOptions = new Dictionary(); if (IncludePorts) { queryOptions["includePorts"] = TrueOption; } if (IncludeAccessControl) { queryOptions["includeAccessControl"] = TrueOption; } if (TokenScopes != null) { TunnelAccessControl.ValidateScopes( TokenScopes, validScopes: null, allowMultiple: true); queryOptions["tokenScopes"] = TokenScopes; } if (ForceRename) { queryOptions["forceRename"] = TrueOption; } if (Tags != null && Tags.Length > 0) { queryOptions["tags"] = Tags; if (RequireAllTags) { queryOptions["allTags"] = TrueOption; } } if (Limit != null) { queryOptions["limit"] = new[] { Limit!.Value.ToString() }; } if (AdditionalQueryParameters != null) { foreach (var queryParam in AdditionalQueryParameters) { queryOptions.Add(queryParam.Key, new string[] {queryParam.Value}); } } // Note the comma separator for multi-valued options is NOT URL-encoded. return string.Join('&', queryOptions.Select( (o) => $"{o.Key}={string.Join(",", o.Value.Select(HttpUtility.UrlEncode))}")); } /// /// Set HTTP request options. /// /// /// On net 6.0+ it sets request.Options. /// On netcoreapp 3.1 it sets request.Properties. /// /// Http request, not null. internal void SetRequestOptions(HttpRequestMessage request) { Requires.NotNull(request, nameof(request)); foreach (var kvp in HttpRequestOptions ?? Enumerable.Empty>()) { #if NET6_0_OR_GREATER request.Options.Set(new HttpRequestOptionsKey(kvp.Key), kvp.Value); #else request.Properties[kvp.Key] = kvp.Value; #endif } } } } dev-tunnels-0.0.25/cs/src/Management/TunnelUserAgent.cs000066400000000000000000000033731450757157500227520ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System.Net.Http.Headers; using System.Reflection; namespace Microsoft.DevTunnels.Management; /// /// User Agent helper for . /// public static class TunnelUserAgent { /// /// Get user agent from , /// using or as the product name, /// and or /// as product version. /// /// Optional assembly to get the version and product name from. /// Optional product name. /// Product info header value or null. public static ProductInfoHeaderValue? GetUserAgent(Assembly? assembly, string? productName = null) { if (productName == null) { if (assembly != null) { productName = assembly.GetName().Name?.Replace('.', '-'); } if (productName == null) { return null; } } string? productVersion = null; if (assembly != null) { productVersion = assembly.GetCustomAttribute()?.InformationalVersion ?? assembly.GetName()?.Version?.ToString(); } return new ProductInfoHeaderValue(productName, productVersion ?? "unknown"); } } dev-tunnels-0.0.25/cs/src/Management/UnauthorizedAccessExceptionExtensions.cs000066400000000000000000000040751450757157500274310ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Collections.Generic; using System.Linq; using System.Net.Http.Headers; namespace Microsoft.DevTunnels.Management; /// /// Extension methods for . /// public static class UnauthorizedAccessExceptionExtensions { private const string AuthenticationSchemesKey = "AuthenticationSchemes"; /// /// Gets the list of schemes that may be used to authenticate, when an /// was thrown for an unauthenticated request. /// public static IEnumerable? GetAuthenticationSchemes( this UnauthorizedAccessException ex) { Requires.NotNull(ex, nameof(ex)); lock (ex.Data) { var authenticationSchemes = ex.Data[AuthenticationSchemesKey] as string[]; return authenticationSchemes? .Select((s) => AuthenticationHeaderValue.TryParse(s, out var value) ? value : null!) .Where((s) => s != null) .ToArray(); } } /// /// Sets the list of schemes that may be used to authenticate, when an /// was thrown for an unauthenticated request. /// public static void SetAuthenticationSchemes( this UnauthorizedAccessException ex, IEnumerable? authenticationSchemes) { SetAuthenticationSchemes(ex, authenticationSchemes? .Select((s) => s?.ToString()!) .Where((s) => s != null)); } internal static void SetAuthenticationSchemes( this UnauthorizedAccessException ex, IEnumerable? authenticationSchemes) { lock (ex.Data) { ex.Data[AuthenticationSchemesKey] = authenticationSchemes?.ToArray(); } } } dev-tunnels-0.0.25/cs/test/000077500000000000000000000000001450757157500154465ustar00rootroot00000000000000dev-tunnels-0.0.25/cs/test/TunnelsSDK.Test/000077500000000000000000000000001450757157500203565ustar00rootroot00000000000000dev-tunnels-0.0.25/cs/test/TunnelsSDK.Test/LocalPortsFixture.cs000066400000000000000000000021001450757157500243270ustar00rootroot00000000000000ďťżusing System.Net; using System.Net.Sockets; namespace Microsoft.DevTunnels.Test { public class LocalPortsFixture : IDisposable { private readonly TcpListener listener; private readonly TcpListener listener1; public LocalPortsFixture() { // Get the local tcp ports this.listener = new TcpListener(IPAddress.Loopback, port: 0); this.listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, false); this.listener.Start(); this.listener1 = new TcpListener(IPAddress.Loopback, port: 0); this.listener1.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, false); this.listener1.Start(); } public ushort Port => (ushort)(((IPEndPoint)this.listener.LocalEndpoint).Port); public ushort Port1 => (ushort)(((IPEndPoint)this.listener1.LocalEndpoint).Port); public void Dispose() { this.listener.Stop(); this.listener1.Stop(); } } } dev-tunnels-0.0.25/cs/test/TunnelsSDK.Test/Mocks/000077500000000000000000000000001450757157500214325ustar00rootroot00000000000000dev-tunnels-0.0.25/cs/test/TunnelsSDK.Test/Mocks/MockTunnelManagementClient.cs000066400000000000000000000244771450757157500272120ustar00rootroot00000000000000using Microsoft.DevTunnels.Contracts; using Microsoft.DevTunnels.Management; namespace Microsoft.DevTunnels.Test.Mocks; public class MockTunnelManagementClient : ITunnelManagementClient { private uint idCounter = 0; public IList Tunnels { get; } = new List(); public string HostRelayUri { get; set; } public string ClientRelayUri { get; set; } public ICollection KnownSubjects { get; } = new List(); public Task ListTunnelsAsync( string clusterId, string domain, TunnelRequestOptions options, bool? ownedTunnelsOnly, CancellationToken cancellation) { IEnumerable tunnels = Tunnels; domain ??= string.Empty; tunnels = tunnels.Where((t) => (t.Domain ?? string.Empty) == domain); return Task.FromResult(tunnels.ToArray()); } public Task GetTunnelAsync( Tunnel tunnel, TunnelRequestOptions options, CancellationToken cancellation) { string clusterId = tunnel.ClusterId; string tunnelId = tunnel.TunnelId; string name = tunnel.Name; tunnel = Tunnels.FirstOrDefault((t) => !string.IsNullOrEmpty(name) ? t.Name == name || t.TunnelId == name : t.ClusterId == clusterId && t.TunnelId == tunnelId); IssueMockTokens(tunnel, options); return Task.FromResult(tunnel); } public async Task CreateTunnelAsync( Tunnel tunnel, TunnelRequestOptions options, CancellationToken cancellation) { if ((await GetTunnelAsync(tunnel, options, cancellation)) != null) { throw new InvalidOperationException("Tunnel already exists."); } tunnel.TunnelId = "tunnel" + (++this.idCounter); tunnel.ClusterId = "localhost"; Tunnels.Add(tunnel); IssueMockTokens(tunnel, options); return tunnel; } public Task UpdateTunnelAsync( Tunnel tunnel, TunnelRequestOptions options, CancellationToken cancellation) { foreach (var t in Tunnels) { if (t.ClusterId == tunnel.ClusterId && t.TunnelId == tunnel.TunnelId) { if (tunnel.Name != null) { t.Name = tunnel.Name; } if (tunnel.Options != null) { t.Options = tunnel.Options; } if (tunnel.AccessControl != null) { t.AccessControl = tunnel.AccessControl; } } } IssueMockTokens(tunnel, options); return Task.FromResult(tunnel); } public Task DeleteTunnelAsync( Tunnel tunnel, TunnelRequestOptions options, CancellationToken cancellation) { for (var i = 0; i < Tunnels.Count; i++) { var t = Tunnels[i]; if (t.ClusterId == tunnel.ClusterId && t.TunnelId == tunnel.TunnelId) { Tunnels.RemoveAt(i); return Task.FromResult(true); } } return Task.FromResult(false); } public Task UpdateTunnelEndpointAsync( Tunnel tunnel, TunnelEndpoint endpoint, TunnelRequestOptions options = null, CancellationToken cancellation = default) { tunnel.Endpoints ??= Array.Empty(); for (int i = 0; i < tunnel.Endpoints.Length; i++) { if (tunnel.Endpoints[i].HostId == endpoint.HostId && tunnel.Endpoints[i].ConnectionMode == endpoint.ConnectionMode) { tunnel.Endpoints[i] = endpoint; return Task.FromResult(endpoint); } } var newArray = new TunnelEndpoint[tunnel.Endpoints.Length + 1]; Array.Copy(tunnel.Endpoints, newArray, tunnel.Endpoints.Length); newArray[newArray.Length - 1] = endpoint; tunnel.Endpoints = newArray; if (endpoint is TunnelRelayTunnelEndpoint tunnelRelayEndpoint) { const string TunnelIdUriToken = "{tunnelId}"; tunnelRelayEndpoint.HostRelayUri = HostRelayUri? .Replace(TunnelIdUriToken, tunnel.TunnelId); tunnelRelayEndpoint.ClientRelayUri = ClientRelayUri? .Replace(TunnelIdUriToken, tunnel.TunnelId); } return Task.FromResult(endpoint); } public Task DeleteTunnelEndpointsAsync( Tunnel tunnel, string hostId, TunnelConnectionMode? connectionMode, TunnelRequestOptions options = null, CancellationToken cancellation = default) { Requires.NotNullOrEmpty(hostId, nameof(hostId)); if (tunnel.Endpoints == null) { return Task.FromResult(false); } var initialLength = tunnel.Endpoints.Length; tunnel.Endpoints = tunnel.Endpoints .Where((ep) => ep.HostId == hostId && (connectionMode == null || ep.ConnectionMode == connectionMode)) .ToArray(); return Task.FromResult(tunnel.Endpoints.Length < initialLength); } public Task ListTunnelPortsAsync( Tunnel tunnel, TunnelRequestOptions options, CancellationToken cancellation) { throw new NotImplementedException(); } public Task GetTunnelPortAsync( Tunnel tunnel, ushort portNumber, TunnelRequestOptions options, CancellationToken cancellation) { throw new NotImplementedException(); } public Task CreateTunnelPortAsync( Tunnel tunnel, TunnelPort tunnelPort, TunnelRequestOptions options, CancellationToken cancellation) { tunnelPort = new TunnelPort { TunnelId = tunnel.TunnelId, ClusterId = tunnel.ClusterId, PortNumber = tunnelPort.PortNumber, Protocol = tunnelPort.Protocol, IsDefault = tunnelPort.IsDefault, AccessControl = tunnelPort.AccessControl, Options = tunnelPort.Options, SshUser = tunnelPort.SshUser, }; tunnel.Ports = (tunnel.Ports ?? Enumerable.Empty()) .Concat(new[] { tunnelPort }).ToArray(); return Task.FromResult(tunnelPort); } public Task UpdateTunnelPortAsync( Tunnel tunnel, TunnelPort tunnelPort, TunnelRequestOptions options, CancellationToken cancellation) { throw new NotImplementedException(); } public Task DeleteTunnelPortAsync( Tunnel tunnel, ushort portNumber, TunnelRequestOptions options, CancellationToken cancellation) { var tunnelPort = tunnel.Ports?.FirstOrDefault((p) => p.PortNumber == portNumber); if (tunnelPort == null) { return Task.FromResult(false); } tunnel.Ports = tunnel.Ports.Where((p) => p != tunnelPort).ToArray(); return Task.FromResult(true); } public Task SearchTunnelsAsync( string[] tags, bool requireAllTags, string clusterId, string domain, TunnelRequestOptions options, CancellationToken cancellation) { IEnumerable tunnels; if (!requireAllTags) { tunnels = Tunnels.Where(tunnel => (tunnel.Tags != null) && (tunnel.Tags.Intersect(tags).Count() > 0)); } else { var numTags = tags.Length; tunnels = Tunnels.Where(tunnel => (tunnel.Tags != null) && (tunnel.Tags.Intersect(tags).Count() == numTags)); } domain ??= string.Empty; tunnels = tunnels.Where((t) => (t.Domain ?? string.Empty) == domain); return Task.FromResult(tunnels.ToArray()); } public Task FormatSubjectsAsync( TunnelAccessSubject[] subjects, TunnelRequestOptions options, CancellationToken cancellation = default) { var formattedSubjects = new List(subjects.Length); foreach (var subject in subjects) { var knownSubject = KnownSubjects.FirstOrDefault((s) => s.Id == subject.Id); formattedSubjects.Add(knownSubject ?? subject); } return Task.FromResult(formattedSubjects.ToArray()); } public Task ResolveSubjectsAsync( TunnelAccessSubject[] subjects, TunnelRequestOptions options, CancellationToken cancellation = default) { var resolvedSubjects = new List(subjects.Length); foreach (var subject in subjects) { var matchingSubjects = KnownSubjects .Where((s) => s.Name.Contains(subject.Name, StringComparison.OrdinalIgnoreCase)) .ToArray(); if (matchingSubjects.Length == 0) { resolvedSubjects.Add(subject); } else if (matchingSubjects.Length == 1) { resolvedSubjects.Add(matchingSubjects[0]); } else { subject.Matches = matchingSubjects; resolvedSubjects.Add(subject); } } return Task.FromResult(resolvedSubjects.ToArray()); } public void Dispose() { throw new NotImplementedException(); } private static void IssueMockTokens(Tunnel tunnel, TunnelRequestOptions options) { if (tunnel != null && options?.TokenScopes != null) { tunnel.AccessTokens = new Dictionary(); foreach (var scope in options.TokenScopes) { tunnel.AccessTokens[scope] = "mock-token"; } } } public Task ListClustersAsync(CancellationToken cancellation = default) { throw new NotImplementedException(); } public Task CheckNameAvailabilityAsync(string name, CancellationToken cancellation = default) { throw new NotImplementedException(); } public Task ListUserLimitsAsync(CancellationToken cancellation = default) { throw new NotImplementedException(); } } dev-tunnels-0.0.25/cs/test/TunnelsSDK.Test/Mocks/MockTunnelRelayStreamFactory.cs000066400000000000000000000017521450757157500275460ustar00rootroot00000000000000using Microsoft.DevTunnels.Connections; using Xunit; namespace Microsoft.DevTunnels.Test.Mocks; public class MockTunnelRelayStreamFactory : ITunnelRelayStreamFactory { private readonly string connectionType; private readonly Stream stream; public MockTunnelRelayStreamFactory(string connectionType, Stream stream = null) { this.connectionType = connectionType; this.stream = stream; StreamFactory = (string accessToken) => Task.FromResult(this.stream); } public Func> StreamFactory { get; set; } public async Task<(Stream, string)> CreateRelayStreamAsync( Uri relayUri, string accessToken, string[] subprotocols, CancellationToken cancellation) { Assert.NotNull(relayUri); Assert.NotNull(accessToken); Assert.Contains(this.connectionType, subprotocols); var stream = await StreamFactory(accessToken); return (stream, this.connectionType); } } dev-tunnels-0.0.25/cs/test/TunnelsSDK.Test/TaskExtensions.cs000066400000000000000000000027131450757157500236720ustar00rootroot00000000000000using System; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; namespace Microsoft.DevTunnels.Test; internal static class TaskExtensions { public static async Task WithTimeout(this Task t, TimeSpan timeout) { // Don't timeout when a debugger is attached. if (Debugger.IsAttached) { await t; return; } Task returnedTask = await Task.WhenAny(t, Task.Delay(timeout)); if (returnedTask != t) { throw new TimeoutException(); } await t; } public static async Task WithTimeout(this Task t, TimeSpan timeout) { // Don't timeout when a debugger is attached. if (Debugger.IsAttached) { return await t; } Task returnedTask = await Task.WhenAny(t, Task.Delay(timeout)); if (returnedTask != t) { throw new TimeoutException(); } return await t; } public static async Task WaitUntil( Func condition, CancellationToken cancellation = default) { while (!condition()) { await Task.Delay(5, cancellation); } } public static async Task WaitUntil( Func> condition, CancellationToken cancellation = default) { while (!(await condition())) { await Task.Delay(5, cancellation); } } } dev-tunnels-0.0.25/cs/test/TunnelsSDK.Test/TcpUtils.cs000066400000000000000000000005311450757157500224530ustar00rootroot00000000000000ďťżusing System.Net; using System.Net.Sockets; namespace Microsoft.DevTunnels.Test; internal static class TcpUtils { public static int GetAvailableTcpPort() { // Get any available local tcp port var l = new TcpListener(IPAddress.Loopback, 0); l.Start(); int port = ((IPEndPoint)l.LocalEndpoint).Port; l.Stop(); return port; } } dev-tunnels-0.0.25/cs/test/TunnelsSDK.Test/TestTunnelRelayTunnelClient.cs000066400000000000000000000007641450757157500263430ustar00rootroot00000000000000ďťżusing Microsoft.DevTunnels.Connections; using System.Diagnostics; namespace Microsoft.DevTunnels.Test; internal class TestTunnelRelayTunnelClient : TunnelRelayTunnelClient { public TestTunnelRelayTunnelClient(TraceSource trace) : base(trace) { } protected override void OnSshSessionClosed(Exception exception) { base.OnSshSessionClosed(exception); SshSessionClosed?.Invoke(this, EventArgs.Empty); } public new event EventHandler SshSessionClosed; } dev-tunnels-0.0.25/cs/test/TunnelsSDK.Test/TunnelAccessTests.cs000066400000000000000000000110211450757157500243120ustar00rootroot00000000000000using System; using Microsoft.DevTunnels.Contracts; using Microsoft.DevTunnels.Management; using Xunit; namespace Microsoft.DevTunnels.Test; /// /// Tests that validate tunnel access control APIs. /// public class TunnelAccessTests { [Fact] public void IsAnonymousAllowed() { var accessControl = new TunnelAccessControl { Entries = new[] { new TunnelAccessControlEntry { Type = TunnelAccessControlEntryType.Anonymous, Scopes = new[] { TunnelAccessScopes.Connect }, }, }, }; Assert.True(accessControl.IsAnonymousAllowed( TunnelAccessScopes.Connect)); Assert.Null(accessControl.IsAllowed( TunnelAccessControlEntryType.Users, "test", TunnelAccessScopes.Connect)); } [Fact] public void IsUserAllowed() { var accessControl = new TunnelAccessControl { Entries = new[] { new TunnelAccessControlEntry { Type = TunnelAccessControlEntryType.Users, Provider = TunnelAccessControlEntry.Providers.Microsoft, Scopes = new[] { TunnelAccessScopes.Connect }, Subjects = new[] { "test" }, }, }, }; Assert.True(accessControl.IsAllowed( TunnelAccessControlEntryType.Users, "test", TunnelAccessScopes.Connect)); Assert.Null(accessControl.IsAnonymousAllowed(TunnelAccessScopes.Connect)); } [Fact] public void IsDeniedAnonymousAllowed() { var accessControl = new TunnelAccessControl { Entries = new[] { new TunnelAccessControlEntry { Type = TunnelAccessControlEntryType.Anonymous, Scopes = new[] { TunnelAccessScopes.Connect }, IsDeny = true, IsInherited = true, }, new TunnelAccessControlEntry { Type = TunnelAccessControlEntryType.Anonymous, Scopes = new[] { TunnelAccessScopes.Connect }, }, }, }; Assert.False(accessControl.IsAnonymousAllowed(TunnelAccessScopes.Connect)); } [Fact] public void IsDeniedUserAllowed() { var accessControl = new TunnelAccessControl { Entries = new[] { new TunnelAccessControlEntry { Type = TunnelAccessControlEntryType.Users, Provider = TunnelAccessControlEntry.Providers.Microsoft, Scopes = new[] { TunnelAccessScopes.Connect }, Subjects = new[] { "test" }, IsDeny = true, IsInherited = true, }, new TunnelAccessControlEntry { Type = TunnelAccessControlEntryType.Users, Provider = TunnelAccessControlEntry.Providers.Microsoft, Scopes = new[] { TunnelAccessScopes.Connect }, Subjects = new[] { "test" }, }, }, }; Assert.False(accessControl.IsAllowed( TunnelAccessControlEntryType.Users, "test", TunnelAccessScopes.Connect)); Assert.Null(accessControl.IsAnonymousAllowed(TunnelAccessScopes.Connect)); } [Fact] public void IsInverseDeniedOrgAllowed() { var accessControl = new TunnelAccessControl { Entries = new[] { // Deny access to anyone who is NOT in the org. new TunnelAccessControlEntry { Type = TunnelAccessControlEntryType.Organizations, Provider = TunnelAccessControlEntry.Providers.Microsoft, Scopes = new[] { TunnelAccessScopes.Connect }, Subjects = new[] { "test" }, IsDeny = true, IsInverse = true, }, }, }; Assert.False(accessControl.IsAllowed( TunnelAccessControlEntryType.Organizations, "test2", TunnelAccessScopes.Connect)); Assert.Null(accessControl.IsAllowed( TunnelAccessControlEntryType.Organizations, "test", TunnelAccessScopes.Connect)); Assert.Null(accessControl.IsAnonymousAllowed(TunnelAccessScopes.Connect)); } } dev-tunnels-0.0.25/cs/test/TunnelsSDK.Test/TunnelConstraintsTests.cs000066400000000000000000000047571450757157500254420ustar00rootroot00000000000000using System; using Microsoft.DevTunnels.Contracts; using Xunit; namespace Microsoft.DevTunnels.Test; using static TunnelConstraints; /// /// Tests the validate . /// public class TunnelConstraintsTests { [Theory] [InlineData("01234567")] [InlineData("89bcdfgh")] [InlineData("stvwxzzz")] public void IsValidTunnelId_Valid(string tunnelId) { Assert.True(IsValidOldTunnelId(tunnelId)); ValidateOldTunnelId(tunnelId); } [Theory] [InlineData(null)] [InlineData("")] [InlineData("0000000")] // 7 chars - shorter [InlineData("000000000")] // 9 chars - longer [InlineData("000-0000")] // 8 chars with invalid char ('-') public void IsValidTunnelId_NotValid(string tunnelId) { Assert.False(IsValidOldTunnelId(tunnelId)); if (tunnelId == null) { Assert.Throws(() => ValidateOldTunnelId(tunnelId)); } else { Assert.Throws(() => ValidateOldTunnelId(tunnelId)); } } [Theory] [InlineData("012345679")] [InlineData("89bcdfg")] [InlineData("test")] [InlineData("aaa")] [InlineData("bcd-ghjk")] [InlineData("jfullerton44-name-with-special-char--jrw9q5vrfjpwx")] [InlineData("012345678901234567890123456789012345678901234567890123456789")] public void IsValidTunnelName_Valid(string tunnelName) { Assert.True(IsValidTunnelName(tunnelName)); } [Theory] [InlineData("a")] [InlineData("0123456789012345678901234567890123456789012345678901234567890")] [InlineData("89bcdfgh")] [InlineData("stvwxzzz")] [InlineData("stv wxzzz")] [InlineData("aaaa-bbb-ccc!!!")] public void IsValidTunnelName_NotValid(string tunnelName) { Assert.False(IsValidTunnelName(tunnelName)); } [Theory] [InlineData("012345679")] [InlineData("89bcdfg")] [InlineData("test")] [InlineData("aa=a")] [InlineData("bcd-ghjk")] [InlineData("jfullerton44-name-with-special-char--jrw9q5vrfjpwx")] [InlineData("codespace_id=2e0ffd29-b8fa-42bd-94bc-e764a8381ca9")] public void IsValidTunnelTag_Valid(string tag) { Assert.True(IsValidTag(tag)); } [Theory] [InlineData("a ")] [InlineData("89bcdfg,h")] [InlineData("stv wxzzz")] [InlineData("aaaa-bbb-ccc!!!")] public void IsValidTunnelTag_NotValid(string tag) { Assert.False(IsValidTag(tag)); } } dev-tunnels-0.0.25/cs/test/TunnelsSDK.Test/TunnelExtensionsTests.cs000066400000000000000000000106761450757157500252670ustar00rootroot00000000000000using System.Text; using Microsoft.DevTunnels.Contracts; using Microsoft.DevTunnels.Management; using Xunit; using static System.Formats.Asn1.AsnWriter; namespace Microsoft.DevTunnels.Test; public class TunnelExtensionsTests { private static Tunnel Tunnel { get; } = new Tunnel { AccessTokens = new Dictionary { ["scope1"] = "token1", ["scope2 scope3 scope4"] = "token2", [" scope5"] = "token3", ["scope6 "] = "token4", ["scope3"] = "token5", ["scope7"] = "", ["scope8 scope9"] = null, } }; [Fact] public void TryGetAccessToken_NullTunnel_Throws() => Assert.Throws(() => ((Tunnel)null).TryGetAccessToken("scope", out var _)); [Fact] public void TryGetAccessToken_NullScope_Throws() => Assert.Throws(() => Tunnel.TryGetAccessToken(null, out var _)); [Fact] public void TryGetAccessToken_EmptyScope_Throws() => Assert.Throws(() => Tunnel.TryGetAccessToken(string.Empty, out var _)); [Fact] public void TryGetAccessToken_NullAccessTokens() => Assert.False(new Tunnel().TryGetAccessToken("scope", out var _)); [Fact] public void TryGetValidAccessToken_NullTunnel_Throws() => Assert.Throws(() => ((Tunnel)null).TryGetValidAccessToken("scope", out var _)); [Fact] public void TryGetValidAccessToken_NullScope_Throws() => Assert.Throws(() => Tunnel.TryGetValidAccessToken(null, out var _)); [Fact] public void TryGetValidAccessToken_EmptyScope_Throws() => Assert.Throws(() => Tunnel.TryGetValidAccessToken(string.Empty, out var _)); [Fact] public void TryGetValidAccessToken_NullAccessTokens() => Assert.False(new Tunnel().TryGetValidAccessToken("scope", out var _)); [Theory] [InlineData("scope1", "token1")] [InlineData("scope2", "token2")] [InlineData("scope3", "token2")] [InlineData("scope4", "token2")] [InlineData("scope5", "token3")] [InlineData("scope6", "token4")] public void TryGetAccessToken(string scope, string expectedToken) { Assert.True(Tunnel.TryGetAccessToken(scope, out var accessToken)); Assert.Equal(expectedToken, accessToken); // All tokens in the tunnel are not valid JWT, so validation for expiration doesn't trip. Assert.True(Tunnel.TryGetValidAccessToken(scope, out accessToken)); Assert.Equal(expectedToken, accessToken); } [Theory] [InlineData("scope2 scope3")] [InlineData("token1")] [InlineData("scope7")] [InlineData("scope8")] [InlineData("scope9")] public void TryGetAccessTokenMissingScope(string scope) { Assert.False(Tunnel.TryGetAccessToken(scope, out var accessToken)); Assert.Null(accessToken); // All tokens in the tunnel are not valid JWT, so validation for expiration doesn't trip. Assert.False(Tunnel.TryGetValidAccessToken(scope, out accessToken)); Assert.Null(accessToken); } [Fact] public void TryGetValidAccessTokenNotExipred() { var token = GetToken(isExpired: false); var tunnel = new Tunnel { AccessTokens = new Dictionary { ["scope"] = token, }, }; Assert.True(tunnel.TryGetValidAccessToken("scope", out var accessToken)); Assert.Equal(token, accessToken); } [Fact] public void TryGetValidAccessTokenExipred() { var token = GetToken(isExpired: true); var tunnel = new Tunnel { AccessTokens = new Dictionary { ["scope"] = token, }, }; string accessToken = string.Empty; Assert.Throws(() => tunnel.TryGetValidAccessToken("scope", out accessToken)); Assert.Null(accessToken); } private static string GetToken(bool isExpired) { var exp = DateTimeOffset.UtcNow + (isExpired ? -TimeSpan.FromHours(1) : TimeSpan.FromHours(1)); var claims = $"{{ \"exp\": {exp.ToUnixTimeSeconds():D} }}"; var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(claims)) .TrimEnd('=') .Replace('/', '_') .Replace('+', '-'); return $"header.{payload}.signature"; } } dev-tunnels-0.0.25/cs/test/TunnelsSDK.Test/TunnelHostAndClientTests.cs000066400000000000000000001367621450757157500256340ustar00rootroot00000000000000using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Net; using System.Net.Sockets; using System.Net.WebSockets; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.DevTunnels.Connections; using Microsoft.DevTunnels.Ssh; using Microsoft.DevTunnels.Ssh.Algorithms; using Microsoft.DevTunnels.Ssh.Events; using Microsoft.DevTunnels.Ssh.Tcp; using Microsoft.DevTunnels.Ssh.Tcp.Events; using Microsoft.DevTunnels.Contracts; using Microsoft.DevTunnels.Test.Mocks; using Nerdbank.Streams; using Xunit; namespace Microsoft.DevTunnels.Test; using static TcpUtils; public class TunnelHostAndClientTests : IClassFixture { private const string MockHostRelayUri = "ws://localhost/tunnel/host"; private const string MockClientRelayUri = "ws://localhost/tunnel/client"; private static readonly TraceSource TestTS = new TraceSource(nameof(TunnelHostAndClientTests)); private static readonly TimeSpan Timeout = Debugger.IsAttached ? TimeSpan.FromHours(1) : TimeSpan.FromSeconds(10); private readonly CancellationToken TimeoutToken = new CancellationTokenSource(Timeout).Token; private Stream serverStream; private Stream clientStream; private readonly IKeyPair serverSshKey; private readonly LocalPortsFixture localPortsFixture; static TunnelHostAndClientTests() { // Enabling tracing to debug console. TestTS.Switch.Level = SourceLevels.All; } public TunnelHostAndClientTests(LocalPortsFixture localPortsFixture) { (this.serverStream, this.clientStream) = FullDuplexStream.CreatePair(); this.serverSshKey = SshAlgorithms.PublicKey.ECDsaSha2Nistp384.GenerateKeyPair(); this.localPortsFixture = localPortsFixture; } private Tunnel CreateRelayTunnel(bool addClientEndpoint = true) => CreateRelayTunnel(addClientEndpoint, Enumerable.Empty()); private Tunnel CreateRelayTunnel(params int[] ports) => CreateRelayTunnel(addClientEndpoint: true, ports); private Tunnel CreateRelayTunnel(bool addClientEndpoint, IEnumerable ports) { return new Tunnel { TunnelId = "test", ClusterId = "localhost", AccessTokens = new Dictionary { [TunnelAccessScopes.Host] = "mock-host-token", [TunnelAccessScopes.Connect] = "mock-connect-token", }, Endpoints = addClientEndpoint ? new[] { new TunnelRelayTunnelEndpoint { ConnectionMode = TunnelConnectionMode.TunnelRelay, ClientRelayUri = MockClientRelayUri, } } : null, Ports = ports.Select((p) => new TunnelPort { PortNumber = (ushort)p, }).ToArray(), }; } private SshServerSession CreateSshServerSession() { var sshConfig = new SshSessionConfiguration(); sshConfig.AddService(typeof(PortForwardingService)); var sshSession = new SshServerSession(sshConfig, TestTS); sshSession.Credentials = new[] { this.serverSshKey }; sshSession.Authenticating += (sender, e) => { // SSH client authentication is not yet implemented, so for now only the // "none" authentication type is supported. if (e.AuthenticationType == SshAuthenticationType.ClientNone) { e.AuthenticationTask = Task.FromResult(new ClaimsPrincipal()); } }; return sshSession; } private SshClientSession CreateSshClientSession() { var sshConfig = new SshSessionConfiguration(); sshConfig.AddService(typeof(PortForwardingService)); var sshSession = new SshClientSession(sshConfig, TestTS); sshSession.Authenticating += (sender, e) => { // SSH server (host public key) authentication is not yet implemented. e.AuthenticationTask = Task.FromResult(new ClaimsPrincipal()); }; sshSession.Request += (sender, e) => { e.IsAuthorized = (e.Request.RequestType == "tcpip-forward" || e.Request.RequestType == "cancel-tcpip-forward"); }; return sshSession; } /// /// Connects a relay client to a duplex stream and returns the SSH server session /// on the other end of the stream. /// private async Task ConnectRelayClientAsync( TunnelRelayTunnelClient relayClient, Tunnel tunnel, TunnelConnectionOptions connectionOptions = null, Func> clientStreamFactory = null, CancellationToken cancellation = default) { var sshSession = CreateSshServerSession(); var serverConnectTask = sshSession.ConnectAsync(this.serverStream); var mockTunnelRelayStreamFactory = new MockTunnelRelayStreamFactory( TunnelRelayTunnelClient.WebSocketSubProtocol, this.clientStream); if (clientStreamFactory != null) { mockTunnelRelayStreamFactory.StreamFactory = clientStreamFactory; } relayClient.StreamFactory = mockTunnelRelayStreamFactory; await relayClient.ConnectAsync(tunnel, connectionOptions, cancellation) .WithTimeout(Timeout); await serverConnectTask.WithTimeout(Timeout); return sshSession; } /// /// Connects a relay host to a duplex stream and returns the multi-channel stream /// (SSH session wrapper) on the other end of the duplex stream. /// private async Task ConnectRelayHostAsync( TunnelRelayTunnelHost relayHost, Tunnel tunnel, Func> hostStreamFactory = null, CancellationToken cancellation = default) { var multiChannelStream = new MultiChannelStream(this.serverStream); var serverConnectTask = multiChannelStream.ConnectAsync(); var mockTunnelRelayStreamFactory = new MockTunnelRelayStreamFactory( TunnelRelayTunnelHost.WebSocketSubProtocol, this.clientStream); if (hostStreamFactory != null) { mockTunnelRelayStreamFactory.StreamFactory = hostStreamFactory; } relayHost.StreamFactory = mockTunnelRelayStreamFactory; await relayHost.ConnectAsync(tunnel, cancellation).WithTimeout(Timeout); await serverConnectTask.WithTimeout(Timeout); return multiChannelStream; } [Fact] public void NewRelayClientHasNoConnectionStatus() { var relayClient = new TunnelRelayTunnelClient(TestTS); Assert.Null(relayClient.DisconnectException); Assert.Equal(ConnectionStatus.None, relayClient.ConnectionStatus); } [Fact] public void NewRelayHostHasNoConnectionStatus() { var managementClient = new MockTunnelManagementClient(); managementClient.HostRelayUri = MockHostRelayUri; var relayHost = new TunnelRelayTunnelHost(managementClient, TestTS); Assert.Null(relayHost.DisconnectException); Assert.Equal(ConnectionStatus.None, relayHost.ConnectionStatus); } [Fact] public async Task ConnectRelayClient() { var relayClient = new TunnelRelayTunnelClient(TestTS); Assert.Collection(relayClient.ConnectionModes, new Action[] { (m) => Assert.Equal(TunnelConnectionMode.TunnelRelay, m), }); var tunnel = CreateRelayTunnel(); using var serverSshSession = await ConnectRelayClientAsync(relayClient, tunnel); Assert.Null(relayClient.DisconnectException); } [Fact] public async Task ConnectRelayClientAfterDisconnect() { var relayClient = new TunnelRelayTunnelClient(TestTS); Assert.Collection(relayClient.ConnectionModes, new Action[] { (m) => Assert.Equal(TunnelConnectionMode.TunnelRelay, m), }); var tunnel = CreateRelayTunnel(); using var serverSshSession = await ConnectRelayClientAsync(relayClient, tunnel); Assert.Null(relayClient.DisconnectException); var disconnectCompletion = new TaskCompletionSource(); relayClient.ConnectionStatusChanged += (_, e) => { if (e.Status == ConnectionStatus.Disconnected) { disconnectCompletion.TrySetResult(); } }; await serverSshSession.CloseAsync(SshDisconnectReason.ByApplication).WithTimeout(Timeout); await disconnectCompletion.Task.WithTimeout(Timeout); Assert.Null(relayClient.DisconnectException); (this.serverStream, this.clientStream) = FullDuplexStream.CreatePair(); using var serverSshSession2 = await ConnectRelayClientAsync(relayClient, tunnel); } [Fact] public async Task ConnectRelayClientAfterFail() { var relayClient = new TunnelRelayTunnelClient(TestTS); var tunnel = CreateRelayTunnel(); Task ConnectToRelayAsync(string accessToken) { throw new InvalidOperationException("Test failure."); } await Assert.ThrowsAsync(() => ConnectRelayClientAsync( relayClient, tunnel, null, ConnectToRelayAsync)); (this.serverStream, this.clientStream) = FullDuplexStream.CreatePair(); using var serverSshSession = await ConnectRelayClientAsync(relayClient, tunnel); } [Fact] public async Task ConnectRelayClientAfterCancel() { var relayClient = new TunnelRelayTunnelClient(TestTS); var tunnel = CreateRelayTunnel(); var cancellationSource = new CancellationTokenSource(); async Task ConnectToRelayAsync(string accessToken) { cancellationSource.Cancel(); return await ThrowNotAWebSocket(HttpStatusCode.TooManyRequests); } await Assert.ThrowsAsync(() => ConnectRelayClientAsync( relayClient, tunnel, null, ConnectToRelayAsync, cancellationSource.Token)); (this.serverStream, this.clientStream) = FullDuplexStream.CreatePair(); using var serverSshSession = await ConnectRelayClientAsync(relayClient, tunnel); } [Fact] public async Task ConnectRelayClientDispose() { var relayClient = new TunnelRelayTunnelClient(TestTS); var tunnel = CreateRelayTunnel(); async Task ConnectToRelayAsync(string accessToken) { await relayClient.DisposeAsync(); return await ThrowNotAWebSocket(HttpStatusCode.TooManyRequests); } await Assert.ThrowsAsync( () => ConnectRelayClientAsync(relayClient, tunnel, null, ConnectToRelayAsync)); } [Fact] public async Task ConnectRelayClientAfterDispose() { var relayClient = new TunnelRelayTunnelClient(TestTS); var tunnel = CreateRelayTunnel(); await relayClient.DisposeAsync(); await Assert.ThrowsAsync( () => ConnectRelayClientAsync(relayClient, tunnel)); } [Theory] [InlineData(true)] [InlineData(false)] public async Task ConnectRelayClientRetriesOn429(bool enableRetry) { var connectionOptions = new TunnelConnectionOptions { EnableRetry = enableRetry, }; var relayClient = new TunnelRelayTunnelClient(TestTS); var tunnel = CreateRelayTunnel(); bool firstAttempt = true; async Task ConnectToRelayAsync(string accessToken) { await Task.Yield(); if (firstAttempt) { firstAttempt = false; await ThrowNotAWebSocket(HttpStatusCode.TooManyRequests); } return this.clientStream; } var serverSessionTask = ConnectRelayClientAsync( relayClient, tunnel, connectionOptions, ConnectToRelayAsync); if (enableRetry) { using var serverSshSession = await serverSessionTask; } else { await Assert.ThrowsAsync(() => serverSessionTask); } } [Fact] public async Task ConnectRelayClientCancelRetryOn429() { var relayClient = new TunnelRelayTunnelClient(TestTS); relayClient.RetryingTunnelConnection += (_, e) => e.Retry = false; var tunnel = CreateRelayTunnel(); var ex = await Assert.ThrowsAsync(async () => { await ConnectRelayClientAsync(relayClient, tunnel, null, ConnectToRelayAsync); }); async Task ConnectToRelayAsync(string accessToken) { await Task.Yield(); await ThrowNotAWebSocket(HttpStatusCode.TooManyRequests); return this.clientStream; } Assert.Equal(HttpStatusCode.TooManyRequests, ex.StatusCode); } [Fact] public async Task ConnectRelayClientFailsForUnrecoverableException() { var relayClient = new TunnelRelayTunnelClient(TestTS); var disconnectedException = new TaskCompletionSource(); relayClient.ConnectionStatusChanged += (sender, args) => { if (args.Status == ConnectionStatus.Disconnected) { disconnectedException.TrySetResult(args.DisconnectException); } }; var tunnel = CreateRelayTunnel(); await Assert.ThrowsAsync( "foobar", () => ConnectRelayClientAsync( relayClient, tunnel, null, (_) => throw new ArgumentNullException("foobar"))); Assert.IsType(await disconnectedException.Task); Assert.Equal(ConnectionStatus.Disconnected, relayClient.ConnectionStatus); Assert.IsType(relayClient.DisconnectException); } [Fact] public async Task ConnectRelayClientFailsFor403Forbidden() { var relayClient = new TunnelRelayTunnelClient(TestTS); var disconnectedException = new TaskCompletionSource(); relayClient.ConnectionStatusChanged += (sender, args) => { if (args.Status == ConnectionStatus.Disconnected) { disconnectedException.TrySetResult(args.DisconnectException); } }; var tunnel = CreateRelayTunnel(); await Assert.ThrowsAsync( () => ConnectRelayClientAsync( relayClient, tunnel, null, (_) => ThrowNotAWebSocket(HttpStatusCode.Forbidden))); Assert.IsType(await disconnectedException.Task); Assert.Equal(ConnectionStatus.Disconnected, relayClient.ConnectionStatus); Assert.IsType(relayClient.DisconnectException); } [Fact] public async Task ConnectRelayClientFailsFor401Unauthorized() { var relayClient = new TunnelRelayTunnelClient(TestTS); var tunnel = CreateRelayTunnel(); await Assert.ThrowsAsync( () => ConnectRelayClientAsync( relayClient, tunnel, null, (_) => ThrowNotAWebSocket(HttpStatusCode.Unauthorized))); Assert.Equal(ConnectionStatus.Disconnected, relayClient.ConnectionStatus); Assert.IsType(relayClient.DisconnectException); } [Fact] public async Task ConnectRelayClientSetsConnectionStatus() { var relayClient = new TunnelRelayTunnelClient(TestTS); var clientConnected = new TaskCompletionSource(); relayClient.ConnectionStatusChanged += (sender, args) => { switch (args.Status) { case ConnectionStatus.Connected: clientConnected.TrySetResult(); break; case ConnectionStatus.Disconnected: clientConnected.TrySetException(new Exception("Unexpected disconnection")); break; } }; var tunnel = CreateRelayTunnel(); using var serverSshSession = await ConnectRelayClientAsync(relayClient, tunnel); Assert.Equal(ConnectionStatus.Connected, relayClient.ConnectionStatus); await clientConnected.Task; } [Theory] [InlineData("127.0.0.1")] [InlineData("0.0.0.0")] public async Task ConnectRelayClientAddPort(string localAddress) { var relayClient = new TunnelRelayTunnelClient(TestTS); relayClient.LocalForwardingHostAddress = IPAddress.Parse(localAddress); var tunnel = CreateRelayTunnel(); using var serverSshSession = await ConnectRelayClientAsync(relayClient, tunnel); var pfs = serverSshSession.ActivateService(); var testPort = GetAvailableTcpPort(); using var remotePortStreamer = await pfs.StreamFromRemotePortAsync( IPAddress.Loopback, testPort, CancellationToken.None); Assert.NotNull(remotePortStreamer); Assert.Equal(testPort, remotePortStreamer.RemotePort); var streamOpenedCompletion = new TaskCompletionSource(); remotePortStreamer.StreamOpened += (sender, stream) => { stream.Close(); streamOpenedCompletion.TrySetResult(); }; using var testClient = new TcpClient(); await testClient.ConnectAsync(IPAddress.Loopback, testPort); await streamOpenedCompletion.Task.WithTimeout(Timeout); } [Fact] public async Task ForwardedPortConnectingRetrieveStream() { var testPort = GetAvailableTcpPort(); var managementClient = new MockTunnelManagementClient(); managementClient.HostRelayUri = MockHostRelayUri; SshStream hostStream = null; var relayHost = new TunnelRelayTunnelHost(managementClient, TestTS); relayHost.ForwardConnectionsToLocalPorts = false; relayHost.ForwardedPortConnecting += (object sender, ForwardedPortConnectingEventArgs e) => { if (e.Port == testPort) { hostStream = e.Stream; } }; var tunnel = CreateRelayTunnel(new int[] { testPort } ); await managementClient.CreateTunnelAsync(tunnel, options: null, default); using var multiChannelStream = await ConnectRelayHostAsync(relayHost, tunnel); using var clientRelayStream = await multiChannelStream.OpenStreamAsync( TunnelRelayTunnelHost.ClientStreamChannelType); using var clientSshSession = CreateSshClientSession(); var pfs = clientSshSession.ActivateService(); pfs.AcceptLocalConnectionsForForwardedPorts = false; await clientSshSession.ConnectAsync(clientRelayStream).WithTimeout(Timeout); var clientCredentials = new SshClientCredentials("tunnel", password: null); await clientSshSession.AuthenticateAsync(clientCredentials); await clientSshSession.WaitForForwardedPortAsync(testPort, TimeoutToken); using var sshStream = await clientSshSession.ConnectToForwardedPortAsync(testPort, TimeoutToken); Assert.NotNull(sshStream); Assert.NotNull(hostStream); } [Fact] public async Task ConnectRelayClientAddPortInUse() { var relayClient = new TunnelRelayTunnelClient(TestTS); var tunnel = CreateRelayTunnel(); using var serverSshSession = await ConnectRelayClientAsync(relayClient, tunnel); var pfs = serverSshSession.ActivateService(); var testPort = GetAvailableTcpPort(); var conflictListener = new TcpListener(IPAddress.Loopback, testPort); try { conflictListener.Start(); using var remotePortStreamer = await pfs.StreamFromRemotePortAsync( IPAddress.Loopback, testPort, CancellationToken.None); Assert.NotNull(remotePortStreamer); // The port number should be the same because the host does not know // when the client chose a different port number due to the conflict. Assert.Equal(testPort, remotePortStreamer.RemotePort); } finally { conflictListener.Stop(); } } [Fact] public async Task ConnectRelayClientRemovePort() { var relayClient = new TunnelRelayTunnelClient(TestTS); var tunnel = CreateRelayTunnel(); using var serverSshSession = await ConnectRelayClientAsync(relayClient, tunnel); var pfs = serverSshSession.ActivateService(); var testPort = GetAvailableTcpPort(); using var remotePortStreamer = await pfs.StreamFromRemotePortAsync( IPAddress.Loopback, testPort, CancellationToken.None); Assert.NotNull(remotePortStreamer); Assert.Equal(testPort, remotePortStreamer.RemotePort); // Disposing this object stops forwarding the port. remotePortStreamer.Dispose(); // Now a connection attempt should fail. await Assert.ThrowsAsync(async () => { // This might not fail immediately, because the Dispose() call above does not wait // for the other side to stop. But connections should start failing very shortly. while (true) { using var testClient = new TcpClient(); await testClient.ConnectAsync(IPAddress.Loopback, testPort); } }).WithTimeout(Timeout); } [Fact] public async Task ConnectRelayClientNoLocalConnections() { var relayClient = new TunnelRelayTunnelClient(TestTS) { AcceptLocalConnectionsForForwardedPorts = false, }; var tunnel = CreateRelayTunnel(); using var serverSshSession = await ConnectRelayClientAsync(relayClient, tunnel); var pfs = serverSshSession.ActivateService(); var testPort = GetAvailableTcpPort(); var conflictListener = new TcpListener(IPAddress.Loopback, testPort); try { conflictListener.Start(); var waitForForwardedPortTask = relayClient.WaitForForwardedPortAsync(testPort, TimeoutToken); Assert.NotNull(waitForForwardedPortTask); Assert.False(waitForForwardedPortTask.IsCompleted); using var remotePortStreamer = await pfs.StreamFromRemotePortAsync( IPAddress.Loopback, testPort, TimeoutToken); Assert.NotNull(remotePortStreamer); // Since there is no listener on the Relay client, it'll report the same remote port to the server SSH session. Assert.Equal(testPort, remotePortStreamer.RemotePort); await waitForForwardedPortTask.WaitAsync(TimeoutToken); Assert.Contains(relayClient.ForwardedPorts, p => p.LocalPort == testPort && p.RemotePort == testPort); var streamOpenedCompletion = new TaskCompletionSource(); remotePortStreamer.StreamOpened += (sender, stream) => { stream.Close(); streamOpenedCompletion.TrySetResult(); }; using var clientStream = await relayClient.ConnectToForwardedPortAsync(testPort, TimeoutToken); Assert.NotNull(clientStream); await streamOpenedCompletion.Task.WaitAsync(TimeoutToken); } finally { conflictListener.Stop(); } } [Fact] public async Task ConnectRelayHost() { var managementClient = new MockTunnelManagementClient(); managementClient.HostRelayUri = MockHostRelayUri; var relayHost = new TunnelRelayTunnelHost(managementClient, TestTS); var hostConnected = new TaskCompletionSource(); relayHost.ConnectionStatusChanged += (sender, args) => { switch (args.Status) { case ConnectionStatus.Connected: hostConnected.TrySetResult(); break; case ConnectionStatus.Disconnected: hostConnected.TrySetException(new Exception("Unexpected disconnection")); break; } }; var tunnel = CreateRelayTunnel(); using var multiChannelStream = await ConnectRelayHostAsync(relayHost, tunnel); Assert.Equal(ConnectionStatus.Connected, relayHost.ConnectionStatus); await hostConnected.Task; using var clientRelayStream = await multiChannelStream.OpenStreamAsync( TunnelRelayTunnelHost.ClientStreamChannelType); using var clientSshSession = CreateSshClientSession(); var pfs = clientSshSession.ActivateService(); await clientSshSession.ConnectAsync(clientRelayStream); } [Fact] public async Task ConnectRelayHostAfterDisconnect() { var managementClient = new MockTunnelManagementClient(); managementClient.HostRelayUri = MockHostRelayUri; var relayHost = new TunnelRelayTunnelHost(managementClient, TestTS); var tunnel = CreateRelayTunnel(); using var serverSshSession = await ConnectRelayHostAsync(relayHost, tunnel); var disconnectCompletion = new TaskCompletionSource(); relayHost.ConnectionStatusChanged += (_, e) => { if (e.Status == ConnectionStatus.Disconnected) { disconnectCompletion.TrySetResult(); } }; serverSshSession.Dispose(); await disconnectCompletion.Task.WithTimeout(Timeout); (this.serverStream, this.clientStream) = FullDuplexStream.CreatePair(); using var serverSshSession2 = await ConnectRelayHostAsync(relayHost, tunnel); } [Fact] public async Task ConnectRelayHostAfterFail() { var managementClient = new MockTunnelManagementClient(); managementClient.HostRelayUri = MockHostRelayUri; var relayHost = new TunnelRelayTunnelHost(managementClient, TestTS); var tunnel = CreateRelayTunnel(); Task ConnectToRelayAsync(string accessToken) { throw new InvalidOperationException("Test failure."); } await Assert.ThrowsAsync(() => ConnectRelayHostAsync( relayHost, tunnel, ConnectToRelayAsync)); (this.serverStream, this.clientStream) = FullDuplexStream.CreatePair(); using var serverSshSession = await ConnectRelayHostAsync(relayHost, tunnel); } [Fact] public async Task ConnectRelayHostAfterCancel() { var managementClient = new MockTunnelManagementClient(); managementClient.HostRelayUri = MockHostRelayUri; var relayHost = new TunnelRelayTunnelHost(managementClient, TestTS); var tunnel = CreateRelayTunnel(); var cancellationSource = new CancellationTokenSource(); async Task ConnectToRelayAsync(string accessToken) { cancellationSource.Cancel(); return await ThrowNotAWebSocket(HttpStatusCode.TooManyRequests); } await Assert.ThrowsAsync(() => ConnectRelayHostAsync( relayHost, tunnel, ConnectToRelayAsync, cancellationSource.Token)); (this.serverStream, this.clientStream) = FullDuplexStream.CreatePair(); using var serverSshSession = await ConnectRelayHostAsync(relayHost, tunnel); } [Fact] public async Task ConnectRelayHostDispose() { var managementClient = new MockTunnelManagementClient(); managementClient.HostRelayUri = MockHostRelayUri; var relayHost = new TunnelRelayTunnelHost(managementClient, TestTS); var tunnel = CreateRelayTunnel(); async Task ConnectToRelayAsync(string accessToken) { await relayHost.DisposeAsync(); return await ThrowNotAWebSocket(HttpStatusCode.TooManyRequests); } await Assert.ThrowsAsync( () => ConnectRelayHostAsync(relayHost, tunnel, ConnectToRelayAsync)); } [Fact] public async Task ConnectRelayHostAfterDispose() { var managementClient = new MockTunnelManagementClient(); managementClient.HostRelayUri = MockHostRelayUri; var relayHost = new TunnelRelayTunnelHost(managementClient, TestTS); var tunnel = CreateRelayTunnel(); await relayHost.DisposeAsync(); await Assert.ThrowsAsync( () => ConnectRelayHostAsync(relayHost, tunnel)); } [Fact] public async Task ConnectRelayClientToHostAndReconnectHost() { var managementClient = new MockTunnelManagementClient { HostRelayUri = MockHostRelayUri, ClientRelayUri = MockClientRelayUri, }; // Create and start tunnel host var tunnel = CreateRelayTunnel(addClientEndpoint: false); // Hosting a tunnel adds the endpoint await managementClient.CreateTunnelAsync(tunnel, options: null, default); var relayHost = new TunnelRelayTunnelHost(managementClient, TestTS); var multiChannelStream = await ConnectRelayHostAsync(relayHost, tunnel); var clientMultiChannelStream = new TaskCompletionSource(); clientMultiChannelStream.SetResult(multiChannelStream); // Create and connect tunnel client var relayClient = new TunnelRelayTunnelClient(TestTS) { StreamFactory = new MockTunnelRelayStreamFactory(TunnelRelayTunnelClient.WebSocketSubProtocol) { StreamFactory = async (accessToken) => { return await (await clientMultiChannelStream.Task).OpenStreamAsync(TunnelRelayTunnelHost.ClientStreamChannelType); }, } }; await relayClient.ConnectAsync(tunnel).WithTimeout(Timeout); // Add port to the tunnel host and wait for it on the client var clientPortAdded = new TaskCompletionSource(); relayClient.ForwardedPorts.PortAdded += (sender, args) => clientPortAdded.TrySetResult(args.Port.RemotePort); await managementClient.CreateTunnelPortAsync( tunnel, new TunnelPort { PortNumber = this.localPortsFixture.Port }, options: null, CancellationToken.None); await relayClient.RefreshPortsAsync(CancellationToken.None); Assert.Equal(this.localPortsFixture.Port, await clientPortAdded.Task); // Reconnect the tunnel host clientMultiChannelStream = new TaskCompletionSource(); var reconnectedHostStream = new TaskCompletionSource(); ((MockTunnelRelayStreamFactory)relayHost.StreamFactory).StreamFactory = async (accessToken) => { var result = await reconnectedHostStream.Task; return result; }; await this.serverStream.DisposeAsync(); await this.clientStream.DisposeAsync(); var (serverStream, clientStream) = FullDuplexStream.CreatePair(); var newMultiChannelStream = new MultiChannelStream(serverStream); var serverConnectTask = newMultiChannelStream.ConnectAsync(CancellationToken.None); reconnectedHostStream.TrySetResult(clientStream); await serverConnectTask.WithTimeout(Timeout); clientMultiChannelStream.TrySetResult(newMultiChannelStream); clientPortAdded = new TaskCompletionSource(); await managementClient.CreateTunnelPortAsync( tunnel, new TunnelPort { PortNumber = this.localPortsFixture.Port1 }, options: null, CancellationToken.None); await relayClient.RefreshPortsAsync(CancellationToken.None); Assert.Equal(this.localPortsFixture.Port1, await clientPortAdded.Task); Assert.Contains(relayClient.ForwardedPorts, p => p.RemotePort == this.localPortsFixture.Port); // Clean up await relayClient.DisposeAsync(); await relayHost.DisposeAsync(); } [Fact] public async Task ConnectRelayClientToHostAndReconnectClient() { var managementClient = new MockTunnelManagementClient { HostRelayUri = MockHostRelayUri, ClientRelayUri = MockClientRelayUri, }; // Create and start tunnel host var tunnel = CreateRelayTunnel(addClientEndpoint: false); // Hosting a tunnel adds the endpoint await managementClient.CreateTunnelAsync(tunnel, options: null, default); var relayHost = new TunnelRelayTunnelHost(managementClient, TestTS); var multiChannelStream = await ConnectRelayHostAsync(relayHost, tunnel); var clientMultiChannelStream = new TaskCompletionSource(); clientMultiChannelStream.SetResult(multiChannelStream); var clientConnected = new TaskCompletionSource(); // Create and connect tunnel client var relayClient = new TunnelRelayTunnelClient(TestTS) { StreamFactory = new MockTunnelRelayStreamFactory(TunnelRelayTunnelClient.WebSocketSubProtocol) { StreamFactory = async (accessToken) => { var result = await (await clientMultiChannelStream.Task).OpenStreamAsync(TunnelRelayTunnelHost.ClientStreamChannelType); clientConnected.TrySetResult(result); return result; }, } }; await relayClient.ConnectAsync(tunnel).WithTimeout(Timeout); var clientSshStream = await clientConnected.Task; // Add port to the tunnel host and wait for it on the client var clientPortAdded = new TaskCompletionSource(); relayClient.ForwardedPorts.PortAdded += (sender, args) => clientPortAdded.TrySetResult(args.Port.RemotePort); await managementClient.CreateTunnelPortAsync( tunnel, new TunnelPort { PortNumber = this.localPortsFixture.Port }, options: null, CancellationToken.None); await relayClient.RefreshPortsAsync(CancellationToken.None); Assert.Equal(this.localPortsFixture.Port, await clientPortAdded.Task); // Reconnect the tunnel client var relayClientDisconnected = new TaskCompletionSource(); var relayClientReconnected = new TaskCompletionSource(); relayClient.ConnectionStatusChanged += (sender, args) => { switch (args.Status) { case ConnectionStatus.Connecting: relayClientDisconnected.TrySetResult(); break; case ConnectionStatus.Connected: relayClientReconnected.TrySetResult(); break; } }; await clientSshStream.Channel.CloseAsync(); await relayClientDisconnected.Task.WithTimeout(Timeout); await relayClientReconnected.Task.WithTimeout(Timeout); clientPortAdded = new TaskCompletionSource(); await managementClient.CreateTunnelPortAsync( tunnel, new TunnelPort { PortNumber = this.localPortsFixture.Port1 }, options: null, CancellationToken.None); await relayClient.RefreshPortsAsync(CancellationToken.None); Assert.Equal(this.localPortsFixture.Port1, await clientPortAdded.Task); Assert.Contains(relayClient.ForwardedPorts, p => p.RemotePort == this.localPortsFixture.Port); // Clean up await relayClient.DisposeAsync(); await relayHost.DisposeAsync(); } [Fact] public async Task ConnectRelayClientToHostAndFailToReconnectClient() { var managementClient = new MockTunnelManagementClient { HostRelayUri = MockHostRelayUri, ClientRelayUri = MockClientRelayUri, }; // Create and start tunnel host var tunnel = CreateRelayTunnel(addClientEndpoint: false); // Hosting a tunnel adds the endpoint await managementClient.CreateTunnelAsync(tunnel, options: null, default); var relayHost = new TunnelRelayTunnelHost(managementClient, TestTS); var multiChannelStream = await ConnectRelayHostAsync(relayHost, tunnel); var clientMultiChannelStream = new TaskCompletionSource(); clientMultiChannelStream.SetResult(multiChannelStream); var clientConnected = new TaskCompletionSource(); // Create and connect tunnel client var relayClient = new TestTunnelRelayTunnelClient(TestTS) { StreamFactory = new MockTunnelRelayStreamFactory(TunnelRelayTunnelClient.WebSocketSubProtocol) { StreamFactory = async (accessToken) => { var result = await (await clientMultiChannelStream.Task).OpenStreamAsync(TunnelRelayTunnelHost.ClientStreamChannelType); clientConnected.TrySetResult(result); return result; }, } }; await relayClient.ConnectAsync(tunnel).WithTimeout(Timeout); var clientSshStream = await clientConnected.Task; // Add port to the tunnel host and wait for it on the client var clientPortAdded = new TaskCompletionSource(); relayClient.ForwardedPorts.PortAdded += (sender, args) => clientPortAdded.TrySetResult(args.Port.RemotePort); await managementClient.CreateTunnelPortAsync( tunnel, new TunnelPort { PortNumber = this.localPortsFixture.Port }, options: null, CancellationToken.None); await relayHost.RefreshPortsAsync(CancellationToken.None); Assert.Equal(this.localPortsFixture.Port, await clientPortAdded.Task); // Expect disconnection bool reconnectStarted = false; var relayClientDisconnected = new TaskCompletionSource(); relayClient.ConnectionStatusChanged += (sender, args) => { switch (args.Status) { case ConnectionStatus.Connected: relayClientDisconnected.TrySetException(new Exception("Unexpected reconnection")); break; case ConnectionStatus.Connecting: reconnectStarted = true; break; case ConnectionStatus.Disconnected: if (reconnectStarted) { relayClientDisconnected.TrySetResult(args.DisconnectException); } break; } }; var clientSshSessionClosed = new TaskCompletionSource(); relayClient.SshSessionClosed += (sender, args) => clientSshSessionClosed.TrySetResult(); // Reconnection will fail with WebSocketException emulating Relay returning 404 (tunnel not found). // This is not recoverable and tunnel client reconnection should give up. var wse = new WebSocketException(WebSocketError.NotAWebSocket); wse.Data["HttpStatusCode"] = HttpStatusCode.NotFound; clientMultiChannelStream = new TaskCompletionSource(); clientMultiChannelStream.SetException(wse); // Disconnect the tunnel client await clientSshStream.Channel.CloseAsync(); var disconnectException = await relayClientDisconnected.Task; Assert.IsType(disconnectException); Assert.Equal(wse, disconnectException.InnerException); await clientSshSessionClosed.Task; Assert.IsType(relayClient.DisconnectException); Assert.Equal(wse, relayClient.DisconnectException.InnerException); // Clean up await relayClient.DisposeAsync(); await relayHost.DisposeAsync(); } [Fact] public async Task ConnectRelayHostAutoAddPort() { var managementClient = new MockTunnelManagementClient(); managementClient.HostRelayUri = MockHostRelayUri; var relayHost = new TunnelRelayTunnelHost(managementClient, TestTS); var tunnel = CreateRelayTunnel(GetAvailableTcpPort()); using var multiChannelStream = await ConnectRelayHostAsync(relayHost, tunnel); using var clientRelayStream = await multiChannelStream.OpenStreamAsync( TunnelRelayTunnelHost.ClientStreamChannelType); using var clientSshSession = CreateSshClientSession(); await clientSshSession.ConnectAsync(clientRelayStream).WithTimeout(Timeout); var clientCredentials = new SshClientCredentials("tunnel", password: null); await clientSshSession.AuthenticateAsync(clientCredentials); await TaskExtensions.WaitUntil(() => relayHost.RemoteForwarders.Count > 0) .WithTimeout(Timeout); var forwarder = relayHost.RemoteForwarders.Values.Single(); var forwardedPort = tunnel.Ports.Single(); Assert.Equal((int)forwardedPort.PortNumber, forwarder.LocalPort); Assert.Equal((int)forwardedPort.PortNumber, forwarder.RemotePort); } [Fact] public async Task ConnectRelayHostThenConnectRelayClientToForwardedPortStream() { var managementClient = new MockTunnelManagementClient(); managementClient.HostRelayUri = MockHostRelayUri; var relayHost = new TunnelRelayTunnelHost(managementClient, TestTS); var port = GetAvailableTcpPort(); var tunnel = CreateRelayTunnel(port); using var multiChannelStream = await ConnectRelayHostAsync(relayHost, tunnel); using var clientRelayStream = await multiChannelStream.OpenStreamAsync( TunnelRelayTunnelHost.ClientStreamChannelType); using var clientSshSession = CreateSshClientSession(); await clientSshSession.ConnectAsync(clientRelayStream).WithTimeout(Timeout); var clientCredentials = new SshClientCredentials("tunnel", password: null); await clientSshSession.AuthenticateAsync(clientCredentials); await clientSshSession.WaitForForwardedPortAsync(port, TimeoutToken); using var sshStream = await clientSshSession.ConnectToForwardedPortAsync(port, TimeoutToken); } [Fact] public async Task ConnectRelayHostThenConnectRelayClientToDifferentPort_Fails() { var managementClient = new MockTunnelManagementClient(); managementClient.HostRelayUri = MockHostRelayUri; var relayHost = new TunnelRelayTunnelHost(managementClient, TestTS); var port = GetAvailableTcpPort(); var tunnel = CreateRelayTunnel(port); using var multiChannelStream = await ConnectRelayHostAsync(relayHost, tunnel); using var clientRelayStream = await multiChannelStream.OpenStreamAsync( TunnelRelayTunnelHost.ClientStreamChannelType); using var clientSshSession = CreateSshClientSession(); await clientSshSession.ConnectAsync(clientRelayStream).WithTimeout(Timeout); var clientCredentials = new SshClientCredentials("tunnel", password: null); await clientSshSession.AuthenticateAsync(clientCredentials); await clientSshSession.WaitForForwardedPortAsync(port, TimeoutToken); var differentPort = port < 60_000 ? port + 1 : port - 1; await Assert.ThrowsAsync( () => clientSshSession.ConnectToForwardedPortAsync(differentPort, TimeoutToken)); } [Fact] public async Task ConnectRelayHostAddPort() { var managementClient = new MockTunnelManagementClient(); managementClient.HostRelayUri = MockHostRelayUri; var relayHost = new TunnelRelayTunnelHost(managementClient, TestTS); var tunnel = CreateRelayTunnel(); await managementClient.CreateTunnelAsync(tunnel, options: null, default); using var multiChannelStream = await ConnectRelayHostAsync(relayHost, tunnel); using var clientRelayStream = await multiChannelStream.OpenStreamAsync( TunnelRelayTunnelHost.ClientStreamChannelType); using var clientSshSession = CreateSshClientSession(); await clientSshSession.ConnectAsync(clientRelayStream).WithTimeout(Timeout); var clientCredentials = new SshClientCredentials("tunnel", password: null); await clientSshSession.AuthenticateAsync(clientCredentials); Assert.Empty(relayHost.RemoteForwarders); var testPort = GetAvailableTcpPort(); await managementClient.CreateTunnelPortAsync( tunnel, new TunnelPort { PortNumber = (ushort)testPort }, options: null, CancellationToken.None); await relayHost.RefreshPortsAsync(CancellationToken.None); var forwarder = relayHost.RemoteForwarders.Values.Single(); var forwardedPort = tunnel.Ports.Single(); Assert.Equal((int)forwardedPort.PortNumber, forwarder.LocalPort); Assert.Equal((int)forwardedPort.PortNumber, forwarder.RemotePort); } [Fact] public async Task ConnectRelayHostRemovePort() { var managementClient = new MockTunnelManagementClient(); managementClient.HostRelayUri = MockHostRelayUri; var relayHost = new TunnelRelayTunnelHost(managementClient, TestTS); var testPort = GetAvailableTcpPort(); var tunnel = CreateRelayTunnel(testPort); using var multiChannelStream = await ConnectRelayHostAsync(relayHost, tunnel); using var clientRelayStream = await multiChannelStream.OpenStreamAsync( TunnelRelayTunnelHost.ClientStreamChannelType); using var clientSshSession = CreateSshClientSession(); await clientSshSession.ConnectAsync(clientRelayStream).WithTimeout(Timeout); var clientCredentials = new SshClientCredentials("tunnel", password: null); await clientSshSession.AuthenticateAsync(clientCredentials); await TaskExtensions.WaitUntil(() => relayHost.RemoteForwarders.Count > 0) .WithTimeout(Timeout); await managementClient.DeleteTunnelPortAsync( tunnel, (ushort)testPort, options: null, CancellationToken.None); await relayHost.RefreshPortsAsync(CancellationToken.None); Assert.Empty(relayHost.RemoteForwarders); Assert.Empty(tunnel.Ports); } [Fact] public async Task ConnectClientToStaleEndpoint_RefreshesTunnel() { var tunnel = CreateRelayTunnel(addClientEndpoint: true); var hostPublicKey = this.serverSshKey.GetPublicKeyBytes(this.serverSshKey.KeyAlgorithmName).ToBase64(); tunnel.Endpoints[0].HostPublicKeys = new[] { hostPublicKey }; var managementClient = new MockTunnelManagementClient(); await managementClient.CreateTunnelAsync(tunnel, options: null, default); var staleTunnel = CreateRelayTunnel(addClientEndpoint: true); staleTunnel.Endpoints[0].HostPublicKeys = new[] { "StaleHostPublicKey" }; staleTunnel.TunnelId = tunnel.TunnelId; var relayClient = new TunnelRelayTunnelClient(managementClient, TestTS); var isTunnelHostPublicKeyRefreshed = false; relayClient.ConnectionStatusChanged += (_, e) => isTunnelHostPublicKeyRefreshed |= (e.Status == ConnectionStatus.RefreshingTunnelHostPublicKey); using var session = await ConnectRelayClientAsync(relayClient, staleTunnel); Assert.True(isTunnelHostPublicKeyRefreshed); Assert.Equal(ConnectionStatus.Connected, relayClient.ConnectionStatus); Assert.Equal(tunnel, relayClient.Tunnel); Assert.Equal(hostPublicKey, tunnel.Endpoints[0].HostPublicKeys[0]); } private static Task ThrowNotAWebSocket(HttpStatusCode statusCode) { var wse = new WebSocketException(WebSocketError.NotAWebSocket, $"The server returned status code '{statusCode:D}' when status code '101' was expected."); wse.Data["HttpStatusCode"] = statusCode; throw wse; } } dev-tunnels-0.0.25/cs/test/TunnelsSDK.Test/TunnelManagementClientTests.cs000066400000000000000000000113371450757157500263360ustar00rootroot00000000000000using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using Microsoft.DevTunnels.Contracts; using Microsoft.DevTunnels.Management; using Xunit; namespace Microsoft.DevTunnels.Test; public class TunnelManagementClientTests { private const string TunnelId = "tnnl0001"; private const string ClusterId = "usw2"; private readonly CancellationToken timeout = System.Diagnostics.Debugger.IsAttached ? default : new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token; private readonly ProductInfoHeaderValue userAgent = TunnelUserAgent.GetUserAgent(typeof(TunnelManagementClientTests).Assembly); private readonly Uri tunnelServiceUri = new Uri("https://localhost:3000/"); [Fact] public async Task HttpRequestOptions() { var options = new TunnelRequestOptions() { HttpRequestOptions = new Dictionary { { "foo", "bar" }, { "bazz", 100 }, } }; var tunnel = new Tunnel { TunnelId = TunnelId, ClusterId = ClusterId, }; var handler = new MockHttpMessageHandler( (message, ct) => { Assert.True(message.Options.TryGetValue(new HttpRequestOptionsKey("foo"), out string strValue) && strValue == "bar"); Assert.True(message.Options.TryGetValue(new HttpRequestOptionsKey("bazz"), out int intValue) && intValue == 100); var result = new HttpResponseMessage(HttpStatusCode.OK); result.Content = JsonContent.Create(tunnel); return Task.FromResult(result); }); var client = new TunnelManagementClient(this.userAgent, null, this.tunnelServiceUri, handler); tunnel = await client.GetTunnelAsync(tunnel, options, this.timeout); Assert.NotNull(tunnel); Assert.Equal(TunnelId, tunnel.TunnelId); Assert.Equal(ClusterId, tunnel.ClusterId); } [Fact] public async Task PreserveAccessTokens() { var requestTunnel = new Tunnel { TunnelId = TunnelId, ClusterId = ClusterId, AccessTokens = new Dictionary { [TunnelAccessScopes.Manage] = "manage-token-1", [TunnelAccessScopes.Connect] = "connect-token-1", }, }; var handler = new MockHttpMessageHandler( (message, ct) => { var responseTunnel = new Tunnel { TunnelId = TunnelId, ClusterId = ClusterId, AccessTokens = new Dictionary { [TunnelAccessScopes.Manage] = "manage-token-2", [TunnelAccessScopes.Host] = "host-token-2", }, }; var result = new HttpResponseMessage(HttpStatusCode.OK); result.Content = JsonContent.Create(responseTunnel); return Task.FromResult(result); }); var client = new TunnelManagementClient(this.userAgent, null, this.tunnelServiceUri, handler); var resultTunnel = await client.GetTunnelAsync(requestTunnel, options: null, this.timeout); Assert.NotNull(resultTunnel); Assert.NotNull(resultTunnel.AccessTokens); // Tokens in the request tunnel should be preserved, unless updated by the response. Assert.Collection( resultTunnel.AccessTokens.OrderBy((item) => item.Key), (item) => Assert.Equal(new KeyValuePair( TunnelAccessScopes.Connect, "connect-token-1"), item), // preserved (item) => Assert.Equal(new KeyValuePair( TunnelAccessScopes.Host, "host-token-2"), item), // added (item) => Assert.Equal(new KeyValuePair( TunnelAccessScopes.Manage, "manage-token-2"), item)); // updated } private sealed class MockHttpMessageHandler : DelegatingHandler { private readonly Func> handler; public MockHttpMessageHandler(Func> handler) : base(new HttpClientHandler { AllowAutoRedirect = false, UseDefaultCredentials = false, }) { this.handler = Requires.NotNull(handler, nameof(handler)); } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => this.handler(request, cancellationToken); } } dev-tunnels-0.0.25/cs/test/TunnelsSDK.Test/TunnelsSDK.Test.csproj000066400000000000000000000015301450757157500245070ustar00rootroot00000000000000 net6.0 enable Microsoft.DevTunnels.Test Microsoft.DevTunnels.Test disable true dev-tunnels-0.0.25/cs/tools/000077500000000000000000000000001450757157500156275ustar00rootroot00000000000000dev-tunnels-0.0.25/cs/tools/TunnelsSDK.Generator/000077500000000000000000000000001450757157500215465ustar00rootroot00000000000000dev-tunnels-0.0.25/cs/tools/TunnelsSDK.Generator/ContractWriter.cs000066400000000000000000000065771450757157500250660ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using Microsoft.CodeAnalysis; namespace Microsoft.DevTunnels.Generator; internal abstract class ContractWriter { private static readonly Regex paragraphBreakRegex = new Regex(@" *\ *"); protected readonly string repoRoot; protected readonly string csNamespace; public static string[] SupportedLanguages { get; } = new[] { "TypeScript", "Go", "Java", "Rust" }; public static ContractWriter Create(string language, string repoRoot, string csNamespace) { return language switch { "TypeScript" => new TSContractWriter(repoRoot, csNamespace), "Go" => new GoContractWriter(repoRoot, csNamespace), "Java" => new JavaContractWriter(repoRoot, csNamespace), "Rust" => new RustContractWriter(repoRoot, csNamespace), _ => throw new NotSupportedException("Unsupported contract language: " + language), }; } protected ContractWriter(string repoRoot, string csNamespace) { this.repoRoot = repoRoot; this.csNamespace = csNamespace; } public abstract void WriteContract(ITypeSymbol type, ICollection allTypes); public virtual void WriteCompleted() { } protected string GetAbsolutePath(string relativePath) { return Path.Combine(this.repoRoot, relativePath); } protected string GetRelativePath(string absolutePath) { if (absolutePath.StartsWith(this.repoRoot)) { return absolutePath.Substring(this.repoRoot.Length + 1).Replace("\\", "/"); } return absolutePath; } protected static IEnumerable WrapComment(string comment, int wrapColumn) { var isFirst = true; foreach (var paragraph in paragraphBreakRegex.Split(comment)) { if (isFirst) { isFirst = false; } else { // Insert a blank line between paragraphs. yield return string.Empty; } comment = paragraph; while (comment.Length > wrapColumn) { var i = wrapColumn; while (i > 0 && comment[i] != ' ') { i--; } if (i == 0) { i = comment.IndexOf(' '); } yield return comment.Substring(0, i).TrimEnd(); comment = comment.Substring(i + 1); } yield return comment.TrimEnd(); } } protected static AttributeData? GetAttribute(ISymbol symbol, string attributeName) { return symbol.GetAttributes().FirstOrDefault((a) => a.AttributeClass?.Name == attributeName); } protected static AttributeData? GetObsoleteAttribute(ISymbol symbol) { return GetAttribute(symbol, nameof(ObsoleteAttribute)); } protected static string? GetObsoleteMessage(AttributeData? obsoleteAttribute) { return obsoleteAttribute?.ConstructorArguments.FirstOrDefault().Value?.ToString(); } } dev-tunnels-0.0.25/cs/tools/TunnelsSDK.Generator/ContractsGenerator.cs000066400000000000000000000102711450757157500257050ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Collections.Generic; using System.IO; using System.Linq; using Microsoft.CodeAnalysis; namespace Microsoft.DevTunnels.Generator; [Generator] public class ContractsGenerator : ISourceGenerator { private const string DiagnosticPrefix = "TUN"; private const string DiagnosticCategory = "Tunnels"; private const string ContractsNamespace = "Microsoft.DevTunnels.Contracts"; internal static readonly string[] ExcludedContractTypes = new[] { "TunnelContracts", "ThisAssembly", "Converter", }; public void Initialize(GeneratorInitializationContext context) { #if DEBUG // Note source generators re not covered by normal debugging, // because the generator runs at build time, not at application run-time. // Un-comment the line below to enable debugging at build time. ////System.Diagnostics.Debugger.Launch(); #endif } public void Execute(GeneratorExecutionContext context) { // Path of the ThisAssembly type's location will be like: // cs/bin/obj/[projectname]/Release/net6.0/[assemblyname].Version.cs var thisAssemblyType = context.Compilation.GetSymbolsWithName( nameof(ThisAssembly), SymbolFilter.Type).Single(); var thisAssemblyPath = thisAssemblyType.Locations.Single().GetLineSpan().Path; var repoRoot = Path.GetFullPath(Path.Combine( thisAssemblyPath, "..", "..", "..", "..", "..", "..", "..")); var writers = new List(); foreach (var language in ContractWriter.SupportedLanguages) { writers.Add(ContractWriter.Create(language, repoRoot, ContractsNamespace)); } var typeNames = context.Compilation.Assembly.TypeNames; var types = typeNames .SelectMany((t) => context.Compilation.GetSymbolsWithName(t, SymbolFilter.Type)) .OfType() .ToArray(); foreach (var type in types) { if (ExcludedContractTypes.Contains(type!.Name)) { continue; } else if (type.ContainingType != null) { // Nested types will be written as part of their containing type. continue; } else if (type.Name.EndsWith("Attribute")) { // Attributes are excluded from code-generation. continue; } var path = type.Locations.Single().GetLineSpan().Path; foreach (var method in type.GetMembers().OfType() .Where((m) => m.MethodKind == MethodKind.Ordinary)) { if (!method.IsStatic && method.Name != "ToString" && method.Name != "GetEnumerator") { var title = "Tunnel contracts must not have instance methods other than " + "GetEnumerator() or ToString()."; var descriptor = new DiagnosticDescriptor( id: DiagnosticPrefix + "1000", title, messageFormat: title + " Generated contract interfaces cannot support " + "instance methods. Consider converting the method to static " + "or other refactoring.", DiagnosticCategory, DiagnosticSeverity.Error, isEnabledByDefault: true); context.ReportDiagnostic( Diagnostic.Create(descriptor, method.Locations.Single())); } } foreach (var writer in writers) { context.CancellationToken.ThrowIfCancellationRequested(); writer.WriteContract(type, types); } } foreach (var writer in writers) { context.CancellationToken.ThrowIfCancellationRequested(); writer.WriteCompleted(); } } } dev-tunnels-0.0.25/cs/tools/TunnelsSDK.Generator/GoContractWriter.cs000066400000000000000000000412161450757157500253410ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; using Microsoft.CodeAnalysis; namespace Microsoft.DevTunnels.Generator; internal class GoContractWriter : ContractWriter { public GoContractWriter(string repoRoot, string csNamespace) : base(repoRoot, csNamespace) { } public override void WriteContract(ITypeSymbol type, ICollection allTypes) { var csFilePath = GetRelativePath(type.Locations.Single().GetLineSpan().Path); var fileName = ToSnakeCase(type.Name) + ".go"; var filePath = GetAbsolutePath(Path.Combine("go/tunnels", fileName)); var s = new StringBuilder(); s.AppendLine("// Copyright (c) Microsoft Corporation."); s.AppendLine("// Licensed under the MIT license."); s.AppendLine($"// Generated from ../../../{csFilePath}"); s.AppendLine(); s.AppendLine("package tunnels"); s.AppendLine(); var importsOffset = s.Length; var imports = new SortedSet(); if (!WriteContractType(s, type, imports, allTypes)) { return; } imports.Remove(type.Name); if (imports.Count > 0) { var importsString = new StringBuilder(); importsString.AppendLine("import ("); foreach (var import in imports) { importsString.AppendLine($"\t\"{import}\""); } importsString.AppendLine(")"); importsString.AppendLine(); s.Insert(importsOffset, importsString.ToString()); } if (!Directory.Exists(Path.GetDirectoryName(filePath))) { Directory.CreateDirectory(Path.GetDirectoryName(filePath)); } File.WriteAllText(filePath, s.ToString()); } private bool WriteContractType( StringBuilder s, ITypeSymbol type, SortedSet imports, ICollection allTypes) { var members = type.GetMembers(); if (type.BaseType?.Name == nameof(Enum) || members.All((m) => m.DeclaredAccessibility != Accessibility.Public || (m is IFieldSymbol field && ((field.IsConst && field.Type.Name == nameof(String)) || field.Name == "All")) || (m is IMethodSymbol method && method.MethodKind == MethodKind.StaticConstructor))) { WriteEnumContract(s, type); } else if (type.IsStatic && members.All((m) => m.IsStatic)) { WriteStaticClassContract(s, type, imports); } else { // Derived type interfaces will be generated along with the base type. if (type.BaseType != null && type.BaseType.Name != nameof(Object)) { return false; } WriteInterfaceContract(s, type, imports, allTypes); } var nestedTypes = type.GetTypeMembers() .Where((t) => !ContractsGenerator.ExcludedContractTypes.Contains(t.Name)) .ToArray(); foreach (var nestedType in nestedTypes.Where( (t) => !ContractsGenerator.ExcludedContractTypes.Contains(t.Name))) { s.AppendLine(); WriteContractType(s, nestedType, imports, allTypes); } return true; } private void WriteInterfaceContract( StringBuilder s, ITypeSymbol type, SortedSet imports, ICollection allTypes) { s.Append(FormatDocComment(type.GetDocumentationCommentXml(), "")); s.Append($"type {type.Name} struct {{"); var derivedTypes = allTypes.Where( (t) => SymbolEqualityComparer.Default.Equals(t.BaseType, type)).ToArray(); var properties = type.GetMembers() .OfType() .Where((p) => !p.IsStatic) .ToArray(); var maxPropertyNameLength = properties.Length == 0 ? 0 : properties.Select((p) => p.Name.Length).Max(); foreach (var property in properties) { var propertyName = FixPropertyNameCasing(property.Name); s.AppendLine(); s.Append(FormatDocComment(property.GetDocumentationCommentXml(), "\t")); var alignment = new string(' ', maxPropertyNameLength - propertyName.Length); var propertyType = property.Type.ToDisplayString(); var goType = GetGoTypeForCSType(propertyType, property, imports); var jsonTag = GetJsonTagForProperty(property); s.AppendLine($"\t{propertyName}{alignment} {goType} `json:\"{jsonTag}\"`"); } if (derivedTypes.Length > 0) { s.AppendLine(); foreach (var derivedType in derivedTypes.OrderBy((t) => t.Name)) { s.AppendLine($"\t{derivedType.Name}"); } } s.AppendLine("}"); foreach (var derivedType in derivedTypes.OrderBy((t) => t.Name)) { s.AppendLine(); WriteInterfaceContract(s, derivedType, imports, allTypes); } foreach (var field in type.GetMembers().OfType() .Where((f) => f.IsConst)) { var fieldName = FixPropertyNameCasing(field.Name); if (field.DeclaredAccessibility == Accessibility.Internal) { fieldName = TSContractWriter.ToCamelCase(fieldName); } else if (field.DeclaredAccessibility != Accessibility.Public) { continue; } s.AppendLine(); s.Append(FormatDocComment(field.GetDocumentationCommentXml(), "", GetGoDoc(field))); s.AppendLine($"var {fieldName} = \"{field.ConstantValue}\""); } } private void WriteEnumContract(StringBuilder s, ITypeSymbol type) { s.Append(FormatDocComment(type.GetDocumentationCommentXml(), "")); string typeName = type.Name; if (type.ContainingType != null) { typeName = type.ContainingType.Name + typeName; } if (typeName.EndsWith("s")) { var pluralTypeName = typeName; typeName = typeName.Substring(0, typeName.Length - 1); s.AppendLine($"type {pluralTypeName} []{typeName}"); } s.AppendLine($"type {typeName} string"); s.AppendLine(); s.Append("const ("); var fields = type.GetMembers() .OfType() .Where((f) => f.HasConstantValue && f.DeclaredAccessibility == Accessibility.Public) .ToArray(); var maxFieldNameLength = fields.Length == 0 ? 0 : fields.Select((p) => p.Name.Length).Max(); foreach (var field in fields) { s.AppendLine(); s.Append(FormatDocComment(field.GetDocumentationCommentXml(), "\t", GetGoDoc(field))); var alignment = new string(' ', maxFieldNameLength - field.Name.Length); var value = type.BaseType?.Name == "Enum" ? field.Name : field.ConstantValue; s.AppendLine($"\t{typeName}{field.Name}{alignment} {typeName} = \"{value}\""); } s.AppendLine(")"); } private void WriteStaticClassContract( StringBuilder s, ITypeSymbol type, SortedSet imports) { var constLines = new List(); var varLines = new List(); foreach (var member in type.GetMembers()) { var property = member as IPropertySymbol; var field = member as IFieldSymbol; if (!member.IsStatic || !(property?.IsReadOnly == true || field?.IsConst == true)) { continue; } var memberName = FixPropertyNameCasing(member.Name); var value = GetMemberInitializer(member); if (value != null) { foreach (var package in new[] { "strings", "strconv", "regexp" }) { if (value.Contains(package + ".") && !imports.Contains(package)) { imports.Add(package); } } var lines = (value.All((c) => char.IsDigit(c)) || (value.StartsWith("\"") && value.EndsWith("\""))) ? constLines : varLines; lines.Add(FormatDocComment(member.GetDocumentationCommentXml(), "\t", GetGoDoc(member)) .Replace("// Gets a ", "// A ").TrimEnd()); lines.Add($"\t{type.Name}{memberName} = {value}"); lines.Add(string.Empty); } } if (constLines.Count > 0) { constLines.RemoveAt(constLines.Count - 1); s.AppendLine("const ("); constLines.ForEach((line) => s.AppendLine(line)); s.AppendLine(")"); } if (varLines.Count > 0) { varLines.RemoveAt(varLines.Count - 1); s.AppendLine("var ("); varLines.ForEach((line) => s.AppendLine(line)); s.AppendLine(")"); } } private static string ToSnakeCase(string name) { var s = new StringBuilder(name); for (int i = 0; i < s.Length; i++) { if (char.IsUpper(s[i])) { if (i > 0) { s.Insert(i, '_'); i++; } s[i] = char.ToLowerInvariant(s[i]); } } return s.ToString(); } private string FormatDocComment(string? comment, string prefix, List? godoc = null) { if (comment == null) { return string.Empty; } comment = comment.Replace("\r", ""); comment = new Regex("\n *").Replace(comment, " "); comment = new Regex($"") .Replace(comment, (m) => $"`{m.Groups[2].Value}.{m.Groups[3].Value}`"); comment = new Regex($"") .Replace(comment, "`$2`"); var summary = new Regex("(.*)").Match(comment).Groups[1].Value.Trim(); var remarks = new Regex("(.*)").Match(comment).Groups[1].Value.Trim(); var s = new StringBuilder(); foreach (var commentLine in WrapComment(summary, 90 - 3 - prefix.Length)) { s.AppendLine(prefix + "// " + commentLine); } if (!string.IsNullOrEmpty(remarks)) { s.AppendLine(prefix + "//"); foreach (var commentLine in WrapComment(remarks, 90 - 3 - prefix.Length)) { s.AppendLine(prefix + "// " + commentLine); } } if (godoc != null) { foreach (var doc in godoc) { s.AppendLine(prefix + "// " + doc); } } return s.ToString(); } private static string? GetMemberInitializer(ISymbol member) { var location = member.Locations.Single(); var sourceSpan = location.SourceSpan; var sourceText = location.SourceTree!.ToString(); var eolIndex = sourceText.IndexOf('\n', sourceSpan.End); var equalsIndex = sourceText.IndexOf('=', sourceSpan.End); if (equalsIndex < 0 || equalsIndex > eolIndex) { // The member does not have an initializer. return null; } var semicolonIndex = sourceText.IndexOf(';', equalsIndex); if (semicolonIndex < 0) { // Invalid syntax?? return null; } var csExpression = sourceText.Substring( equalsIndex + 1, semicolonIndex - equalsIndex - 1).Trim(); // Attempt to convert the CS expression to a Go expression. This involves several // weak assumptions, and will not work for many kinds of expressions. But it might // be good enough. var goExpression = csExpression.Replace("new Regex", "regexp.MustCompile"); goExpression = new Regex("(\\w+)\\.Replace\\(([^,]*), ([^)]*)\\)") .Replace(goExpression, "strings.Replace($1, $2, $3, -1)"); // Assume any PascalCase identifiers are referncing other variables in scope. // Contvert integer constants to strings, allowing for integer offsets. goExpression = new Regex("([A-Z][a-z]+){2,6}\\b(?!\\()").Replace( goExpression, (m) => $"{member.ContainingType.Name}{m.Value}"); goExpression = new Regex("\\(([A-Z][a-z]+){4,7} - \\d\\)").Replace( goExpression, (m) => $"strconv.Itoa{m.Value}"); goExpression = new Regex("\\b([A-Z][a-z]+){3,6}Length\\b(?! - \\d)").Replace( goExpression, (m) => $"strconv.Itoa({m.Value})"); goExpression = FixPropertyNameCasing(goExpression); goExpression = goExpression.Replace(" ", "\t\t"); return goExpression; } private string GetJsonTagForProperty(IPropertySymbol property) { var tag = TSContractWriter.ToCamelCase(property.Name); if (property.TryGetJsonPropertyName(out var jsonPropertyName)) { tag = jsonPropertyName!; } var jsonIgnoreAttribute = property.GetAttributes() .SingleOrDefault((a) => a.AttributeClass!.Name == "JsonIgnoreAttribute"); if (jsonIgnoreAttribute != null) { if (jsonIgnoreAttribute.NamedArguments.Length != 1 || jsonIgnoreAttribute.NamedArguments[0].Key != "Condition") { // TODO: Diagnostic throw new ArgumentException("JsonIgnoreAttribute must have a condition argument."); } tag += ",omitempty"; } return tag; } private static string FixPropertyNameCasing(string propertyName) { propertyName = propertyName.Replace("Id", "ID"); propertyName = propertyName.Replace("Uri", "URI"); return propertyName; } private string GetGoTypeForCSType(string csType, IPropertySymbol property, SortedSet imports) { var isNullable = csType.EndsWith("?"); if (isNullable) { csType = csType.Substring(0, csType.Length - 1); } var prefix = ""; if (csType.EndsWith("[]")) { prefix = "[]"; csType = csType.Substring(0, csType.Length - 2); isNullable = false; } string goType; if (csType.StartsWith(this.csNamespace + ".")) { goType = csType.Substring(csNamespace.Length + 1); } else { goType = csType switch { "bool" => "bool", "short" => "int16", "ushort" => "uint16", "int" => "int32", "uint" => "uint32", "long" => "int64", "ulong" => "uint64", "string" => "string", "System.DateTime" => "time.Time", "System.Text.RegularExpressions.Regex" => "regexp.Regexp", "System.Collections.Generic.IDictionary" => $"map[{(property.Name == "AccessTokens" ? "TunnelAccessScope" : "string")}]string", "System.Collections.Generic.IDictionary" => "map[string][]string", _ => throw new NotSupportedException("Unsupported C# type: " + csType), }; if (!goType.Contains(".")) { // Struct members of type string and other basic types, arrays, and maps are // conventionally not represented as pointers in Go. An implication is that partial // resource updates may require custom marshalling to omit non-updated fields. isNullable = false; } } if (isNullable) { prefix = "*" + prefix; } if (goType.Contains('.')) { var package = goType.Split('.')[0]; if (!imports.Contains(package)) { imports.Add(package); } } goType = prefix + goType; return goType; } private static List GetGoDoc(ISymbol symbol) { var doc = new List(); var obsoleteAttribute = GetObsoleteAttribute(symbol); if (obsoleteAttribute != null) { var message = GetObsoleteMessage(obsoleteAttribute); doc.Add($"Deprecated: {message}"); } return doc; } } dev-tunnels-0.0.25/cs/tools/TunnelsSDK.Generator/JavaContractWriter.cs000066400000000000000000000425351450757157500256620ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using Microsoft.CodeAnalysis; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; namespace Microsoft.DevTunnels.Generator; internal class JavaContractWriter : ContractWriter { public const string JavaDateTimeType = "java.util.Date"; public const string PackageName = "com.microsoft.tunnels.contracts"; public const string RegexPatternType = "java.util.regex.Pattern"; public const string SerializedNameTagFormat = "@SerializedName(\"{0}\")"; public const string SerializedNameType = $"com.google.gson.annotations.SerializedName"; public const string ClassDeclarationHeader = "public class"; public const string StaticClassDeclarationHeader = "public static class"; public const string EnumDeclarationHeader = "public enum"; public const string GsonExposeType = "com.google.gson.annotations.Expose"; public const string GsonExposeTag = "@Expose"; public const string DeprecatedTag = "@Deprecated"; public JavaContractWriter(string repoRoot, string csNamespace) : base(repoRoot, csNamespace) { } public override void WriteContract(ITypeSymbol type, ICollection allTypes) { var csFilePath = GetRelativePath(type.Locations.Single().GetLineSpan().Path); var fileName = type.Name + ".java"; var filePath = GetAbsolutePath(Path.Combine("java", "src", "main", "java", "com", "microsoft", "tunnels", "contracts", fileName)); var s = new StringBuilder(); s.AppendLine("// Copyright (c) Microsoft Corporation."); s.AppendLine("// Licensed under the MIT license."); s.AppendLine($"// Generated from ../../../../../../../../{csFilePath}"); s.AppendLine(); s.AppendLine($"package {PackageName};"); s.AppendLine(); var importsOffset = s.Length; var imports = new SortedSet(); WriteContractType(s, "", type, imports); imports.Remove(type.Name); if (imports.Count > 0) { var importLines = string.Join(Environment.NewLine, imports.Select( (i) => $"import {i};")) + Environment.NewLine + Environment.NewLine; s.Insert(importsOffset, importLines); } if (!Directory.Exists(Path.GetDirectoryName(filePath))) { Directory.CreateDirectory(Path.GetDirectoryName(filePath)); } File.WriteAllText(filePath, s.ToString()); } private void WriteContractType( StringBuilder s, string indent, ITypeSymbol type, SortedSet imports) { var members = type.GetMembers(); if (type.BaseType?.Name == nameof(Enum)) { WriteEnumContract(s, indent, type); imports.Add(SerializedNameType); } else { WriteClassContract(s, indent, type, imports); } } public void WriteNestedTypes( StringBuilder s, string indent, ITypeSymbol type, SortedSet imports) { var nestedTypes = type.GetTypeMembers() .Where((t) => !ContractsGenerator.ExcludedContractTypes.Contains(t.Name)) .ToArray(); if (nestedTypes.Length > 0) { foreach (var nestedType in nestedTypes.Where( (t) => !ContractsGenerator.ExcludedContractTypes.Contains(t.Name))) { s.AppendLine(); WriteContractType(s, indent + " ", nestedType, imports); } } } private void WriteClassContract( StringBuilder s, string indent, ITypeSymbol type, SortedSet imports) { var baseTypeName = type.BaseType?.Name; if (baseTypeName == nameof(Object)) { baseTypeName = null; } var staticClass = type.IsStatic && type.GetMembers().All((m) => m.IsStatic); if (!staticClass) { imports.Add(GsonExposeType); } var enumClass = type.IsStatic && type.GetMembers() .Where((m) => m.DeclaredAccessibility == Accessibility.Public) .All((m) => m is IFieldSymbol); s.Append(FormatDocComment(type.GetDocumentationCommentXml(), indent)); var extends = ""; if (baseTypeName != null) { extends = " extends " + baseTypeName; } // Only inner classes can be declared static in Java. var header = type.IsStatic && type.ContainingType != null ? StaticClassDeclarationHeader : ClassDeclarationHeader; s.Append($"{indent}{header} {type.Name}{extends} {{"); CopyConstructor(s, indent + " ", type, imports); var serializedNameTagImportAdded = false; foreach (var member in type.GetMembers() .Where((m) => m is IPropertySymbol || m is IFieldSymbol field)) { if (member.DeclaredAccessibility != Accessibility.Public && (enumClass || member.DeclaredAccessibility != Accessibility.Internal)) { continue; } var property = member as IPropertySymbol; var field = member as IFieldSymbol; if (field != null && !field.IsConst) { continue; } s.AppendLine(); s.Append(FormatDocComment(member.GetDocumentationCommentXml(), indent + " ", GetJavaDoc(member))); if (GetObsoleteAttribute(member) != null) { s.AppendLine($"{indent} {DeprecatedTag}"); } var memberType = (property?.Type ?? field!.Type).ToDisplayString(); var isNullable = memberType.EndsWith("?"); if (isNullable) { memberType = memberType.Substring(0, memberType.Length - 1); } var accessMod = member.DeclaredAccessibility == Accessibility.Public ? "public " : ""; var staticKeyword = member.IsStatic ? "static " : ""; var finalKeyword = field?.IsConst == true || property?.IsReadOnly == true ? "final " : ""; var javaName = ToCamelCase(member.Name); var javaType = GetJavaTypeForCSType(memberType, javaName, imports); // Static properties in a non-static class are linked to the non-generated *Statics.java class. var value = field?.IsConst != true && member.IsStatic && !staticClass ? $"{type.Name}Statics.{javaName}" : GetMemberInitializer(member); if (!member.IsStatic && field?.IsConst != true) { if (property.TryGetJsonPropertyName(out var jsonPropertyName)) { s.AppendLine($"{indent} {string.Format(SerializedNameTagFormat, jsonPropertyName)}"); if (!serializedNameTagImportAdded) { imports.Add(SerializedNameType); serializedNameTagImportAdded = true; } } s.AppendLine($"{indent} {GsonExposeTag}"); } if (value != null && !value.Equals("null") && !value.Equals("null!")) { s.AppendLine($"{indent} {accessMod}{staticKeyword}{finalKeyword}{javaType} {javaName} = {value};"); } else { // Uninitialized java fields are null by default. s.AppendLine($"{indent} {accessMod}{staticKeyword}{finalKeyword}{javaType} {javaName};"); } } foreach (var method in type.GetMembers().OfType()) { if (method.IsStatic && method.MethodKind == MethodKind.Ordinary && method.DeclaredAccessibility == Accessibility.Public) { s.AppendLine(); s.Append(FormatDocComment(method.GetDocumentationCommentXml(), indent + " ")); if (GetObsoleteAttribute(method) != null) { s.AppendLine($"{indent} {DeprecatedTag}"); } var javaName = ToCamelCase(method.Name); var javaReturnType = GetJavaTypeForCSType(method.ReturnType.ToDisplayString(), javaName, imports); var parameters = new Dictionary() { }; foreach (var parameter in method.Parameters) { var parameterType = parameter.Type.ToDisplayString(); var javaParameterName = ToCamelCase(parameter.Name); var javaParameterType = GetJavaTypeForCSType(parameterType, javaName, imports); parameters.Add(javaParameterName, javaParameterType); } var parameterString = String.Join(", ", parameters.Select(p => String.Format("{0} {1}", p.Value, p.Key))); var returnKeyword = javaReturnType != "void" ? "return " : ""; s.AppendLine($"{indent} public static {javaReturnType} {javaName}({parameterString}) {{"); s.AppendLine($"{indent} {returnKeyword}{type.Name}Statics.{javaName}({String.Join(", ", parameters.Keys)});"); s.AppendLine($"{indent} }}"); } } WriteNestedTypes(s, indent, type, imports); s.AppendLine($"{indent}}}"); } private void WriteEnumContract( StringBuilder s, string indent, ITypeSymbol type) { s.Append(FormatDocComment(type.GetDocumentationCommentXml(), indent)); s.Append($"{indent}{EnumDeclarationHeader} {type.Name} {{"); foreach (var member in type.GetMembers()) { if (!(member is IFieldSymbol field) || !field.HasConstantValue) { continue; } s.AppendLine(); s.Append(FormatDocComment(field.GetDocumentationCommentXml(), indent + " ", GetJavaDoc(member))); if (member != null && GetObsoleteAttribute(member) != null) { s.AppendLine($"{indent} {DeprecatedTag}"); } s.AppendLine($"{indent} {string.Format(SerializedNameTagFormat, field.Name)}"); s.AppendLine($"{indent} {field.Name},"); } s.AppendLine($"{indent}}}"); } private void CopyConstructor( StringBuilder s, string indent, ITypeSymbol type, SortedSet imports) { foreach (var method in type.GetMembers().OfType()) { if (method.Name == ".ctor") { // We assume that // (1) the constructor only performs property assignments and // (2) the property and parameter names match. // Then we simply do those assignments. var parameters = new Dictionary() { }; foreach (var parameter in method.Parameters) { var parameterType = parameter.Type.ToDisplayString(); var javaName = ToCamelCase(parameter.Name); var javaType = GetJavaTypeForCSType(parameterType, javaName, imports); parameters.Add(javaName, javaType); } // No need to write the default constructor. if (parameters.Count == 0) { return; } s.AppendLine(); var parameterString = parameters.Select(p => String.Format("{0} {1}", p.Value, p.Key)); s.Append($"{indent}{type.Name} ({String.Join(", ", parameterString)}) {{"); s.AppendLine(); foreach (String parameter in parameters.Keys) { s.AppendLine($"{indent} this.{parameter} = {parameter};"); } s.AppendLine($"{indent}}}"); } } } internal static string ToCamelCase(string name) { return name.Substring(0, 1).ToLowerInvariant() + name.Substring(1); } private string FormatDocComment(string? comment, string indent, List? javaDoc = null) { if (comment == null) { return string.Empty; } comment = comment.Replace("\r", ""); comment = new Regex("\n *").Replace(comment, " "); comment = new Regex($"") .Replace(comment, (m) => $"{{@link {m.Groups[2].Value}#{ToCamelCase(m.Groups[3].Value)}}}"); comment = new Regex($"") .Replace(comment, "{@link $2}"); var summary = new Regex("(.*)").Match(comment).Groups[1].Value.Trim(); var remarks = new Regex("(.*)").Match(comment).Groups[1].Value.Trim(); var s = new StringBuilder(); s.AppendLine(indent + "/**"); foreach (var commentLine in WrapComment(summary, 90 - 3 - indent.Length)) { s.AppendLine(indent + " * " + commentLine); } if (!string.IsNullOrEmpty(remarks)) { s.AppendLine(indent + " *"); foreach (var commentLine in WrapComment(remarks, 90 - 3 - indent.Length)) { s.AppendLine(indent + " * " + commentLine); } } if (javaDoc != null) { foreach (var line in javaDoc) { s.AppendLine(indent + " * " + line); } } s.AppendLine(indent + " */"); return s.ToString(); } private static string? GetMemberInitializer(ISymbol member) { var location = member.Locations.Single(); var sourceSpan = location.SourceSpan; var sourceText = location.SourceTree!.ToString(); var eolIndex = sourceText.IndexOf('\n', sourceSpan.End); var equalsIndex = sourceText.IndexOf('=', sourceSpan.End); if (equalsIndex < 0 || equalsIndex > eolIndex) { // The member does not have an initializer. return null; } var semicolonIndex = sourceText.IndexOf(';', equalsIndex); if (semicolonIndex < 0) { // Invalid syntax?? return null; } var csExpression = sourceText.Substring( equalsIndex + 1, semicolonIndex - equalsIndex - 1).Trim(); // Attempt to convert the CS expression to a Java expression. This involes several // weak assumptions, and will not work for many kinds of expressions. But it might // be good enough. var javaExpression = csExpression .Replace("new Regex", $"{RegexPatternType}.compile") .Replace("Replace", "replace"); // Assume any PascalCase identifiers are referncing other variables in scope. javaExpression = new Regex("(?<= |\\()([A-Z][a-z]+){2,6}\\b(?!\\()").Replace( javaExpression, (m) => { return (member.ContainingType.MemberNames.Contains(m.Value) ? member.ContainingType.Name + "." : string.Empty) + ToCamelCase(m.Value); }); return javaExpression; } private string GetJavaTypeForCSType(string csType, string propertyName, SortedSet imports) { var suffix = ""; if (csType.EndsWith("[]")) { suffix = "[]"; csType = csType.Substring(0, csType.Length - 2); } if (csType.EndsWith("?")) { csType = csType.Substring(0, csType.Length - 1); } string javaType; if (csType.StartsWith(this.csNamespace + ".")) { javaType = csType.Substring(csNamespace.Length + 1); } else { javaType = csType switch { "void" => "void", "bool" => "boolean", "short" => "short", "ushort" => "int", "int" => "int", "uint" => "int", "long" => "long", "ulong" => "long", "string" => "String", "System.DateTime" => JavaDateTimeType, "System.Text.RegularExpressions.Regex" => RegexPatternType, "System.Collections.Generic.IDictionary" => $"java.util.Map", "System.Collections.Generic.IDictionary" => $"java.util.Map", "System.Uri" => "java.net.URI", "System.Collections.Generic.IEnumerable" => "java.util.Collection", _ => throw new NotSupportedException("Unsupported C# type: " + csType), }; } if (javaType.Contains('.')) { imports.Add(javaType.Split('<')[0]); javaType = javaType.Split('.').Last(); } javaType += suffix; return javaType; } private static List GetJavaDoc(ISymbol symbol) { var doc = new List(); var obsoleteAttribute = GetObsoleteAttribute(symbol); if (obsoleteAttribute != null) { var message = GetObsoleteMessage(obsoleteAttribute); doc.Add($"@deprecated {message}"); } return doc; } } dev-tunnels-0.0.25/cs/tools/TunnelsSDK.Generator/LanguageExtensions.cs000066400000000000000000000013531450757157500257020ustar00rootroot00000000000000using System.Linq; using Microsoft.CodeAnalysis; using System.Diagnostics.CodeAnalysis; namespace Microsoft.DevTunnels.Generator; internal static class LanguageExtensions { public static bool TryGetJsonPropertyName(this IPropertySymbol? property, out string? jsonPropertyName) { if (property?.GetAttributes().FirstOrDefault((a) => a.AttributeClass?.Name == "JsonPropertyNameAttribute") is AttributeData propertyNameAttribute && propertyNameAttribute.ConstructorArguments.FirstOrDefault().Value?.ToString() is string result && !string.IsNullOrEmpty(result)) { jsonPropertyName = result; return true; } jsonPropertyName = null; return false; } } dev-tunnels-0.0.25/cs/tools/TunnelsSDK.Generator/RustContractWriter.cs000066400000000000000000000415411450757157500257320ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using Microsoft.CodeAnalysis; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; namespace Microsoft.DevTunnels.Generator; internal class RustContractWriter : ContractWriter { // extra, non-generated modules that should be imported but not exported: private readonly List ImportModules = new List() { }; // extra, non-generated modules that should be exported: private readonly List ExportModules = new List() { "tunnel_environments", }; private static readonly ISet DefaultDerivers = new HashSet() { "Tunnel", "TunnelPort", }; public RustContractWriter(string repoRoot, string csNamespace) : base(repoRoot, csNamespace) { } public override void WriteContract(ITypeSymbol type, ICollection allTypes) { var csFilePath = GetRelativePath(type.Locations.Single().GetLineSpan().Path); var moduleName = ToSnakeCase(type.Name); this.ExportModules.Add(moduleName); var fileName = ToSnakeCase(type.Name) + ".rs"; var filePath = GetAbsolutePath(Path.Combine("rs/src/contracts", fileName)); var s = new StringBuilder(); this.AppendFileHeader(s, $"../../../{csFilePath}"); var importsOffset = s.Length; var imports = new SortedSet(); if (!WriteContractType(s, type, imports, allTypes)) { return; } imports.Remove(type.Name); if (imports.Count > 0) { var importsString = new StringBuilder(); foreach (var import in imports) { importsString.AppendLine($"use {import};"); } importsString.AppendLine(); s.Insert(importsOffset, importsString.ToString()); } if (!Directory.Exists(Path.GetDirectoryName(filePath))) { Directory.CreateDirectory(Path.GetDirectoryName(filePath)); } File.WriteAllText(filePath, s.ToString()); } public override void WriteCompleted() { this.WriteModRs(); } private void WriteModRs() { var filePath = GetAbsolutePath("rs/src/contracts/mod.rs"); var s = new StringBuilder(); this.AppendFileHeader(s, "RustContractWriter.cs"); this.ExportModules.Sort(); this.ImportModules.Sort(); foreach (var mod in this.ExportModules) { s.AppendLine($"mod {mod};"); } foreach (var mod in this.ImportModules) { s.AppendLine($"mod {mod};"); } s.AppendLine(); foreach (var mod in this.ExportModules) { s.AppendLine($"pub use {mod}::*;"); } File.WriteAllText(filePath, s.ToString()); } private void AppendFileHeader(StringBuilder s, string generatedFrom) { s.AppendLine("// Copyright (c) Microsoft Corporation."); s.AppendLine("// Licensed under the MIT license."); s.AppendLine($"// Generated from {generatedFrom}"); s.AppendLine(); } private bool WriteContractType( StringBuilder s, ITypeSymbol type, SortedSet imports, ICollection allTypes) { var members = type.GetMembers(); if (type.BaseType?.Name == nameof(Enum)) { WriteEnumContract(s, type, imports); } else if (type.IsStatic && members.All((m) => m.IsStatic)) { WriteStaticClassContract(s, type, imports); } else { WriteInterfaceContract(s, type, imports, allTypes); } var nestedTypes = type.GetTypeMembers() .Where((t) => !ContractsGenerator.ExcludedContractTypes.Contains(t.Name)) .ToArray(); foreach (var nestedType in nestedTypes.Where( (t) => !ContractsGenerator.ExcludedContractTypes.Contains(t.Name))) { s.AppendLine(); WriteContractType(s, nestedType, imports, allTypes); } return true; } private void WriteResourceStatusSerializer(StringBuilder s) { s.AppendLine("#[derive(Clone, Debug, Deserialize, Serialize)]"); s.AppendLine("#[serde(untagged)]"); s.AppendLine("pub enum ResourceStatus {"); s.AppendLine(" Detailed(DetailedResourceStatus),"); s.AppendLine(" Count(u32),"); s.AppendLine("}"); s.AppendLine("impl ResourceStatus {"); s.AppendLine(" pub fn get_count(&self) -> u64 {"); s.AppendLine(" match self {"); s.AppendLine(" ResourceStatus::Detailed(d) => d.current,"); s.AppendLine(" ResourceStatus::Count(c) => (*c).into(),"); s.AppendLine(" }"); s.AppendLine(" }"); s.AppendLine("}"); } private void WriteInterfaceContract( StringBuilder s, ITypeSymbol type, SortedSet imports, ICollection allTypes) { imports.Add("serde::{Deserialize, Serialize}"); var rsName = type.Name; if (rsName == "ResourceStatus") { WriteResourceStatusSerializer(s); rsName = "DetailedResourceStatus"; } s.Append(FormatDocComment(type.GetDocumentationCommentXml(), "")); s.Append("#[derive(Clone, Debug, Deserialize, Serialize"); if (DefaultDerivers.Contains(rsName)) { s.Append(", Default"); } s.AppendLine(")]"); s.AppendLine("#[serde(rename_all(serialize = \"camelCase\", deserialize = \"camelCase\"))]"); s.Append($"pub struct {rsName} {{"); var fullBaseType = type.BaseType?.ToString(); if (fullBaseType != null && fullBaseType.StartsWith(this.csNamespace)) { var rsBaseType = fullBaseType.Substring(this.csNamespace.Length + 1); s.AppendLine(); s.AppendLine(" #[serde(flatten)]"); s.AppendLine($" pub base: {rsBaseType},"); imports.Add($"crate::contracts::{rsBaseType}"); } var properties = type.GetMembers() .OfType() .Where((p) => !p.IsStatic) .ToArray(); var maxPropertyNameLength = properties.Length == 0 ? 0 : properties.Select((p) => ToSnakeCase(p.Name).Length).Max(); foreach (var property in properties) { s.AppendLine(); s.Append(FormatDocComment(property.GetDocumentationCommentXml(), " ")); AppendStructProperty(type, property, imports, s); } s.AppendLine("}"); foreach (var field in type.GetMembers().OfType() .Where((f) => f.IsConst)) { if (field.DeclaredAccessibility != Accessibility.Public && field.DeclaredAccessibility != Accessibility.Internal) { continue; } if (field.ConstantValue is string value) { s.AppendLine(); s.Append(FormatDocComment(field.GetDocumentationCommentXml(), "")); var fieldRsName = ToSnakeCase(field.Name).ToUpperInvariant(); s.AppendLine($"pub const {fieldRsName}: &str = \"{value}\";"); } } } private void WriteEnumContract( StringBuilder s, ITypeSymbol type, SortedSet imports) { imports.Add("serde::{Deserialize, Serialize}"); imports.Add("std::fmt"); s.Append(FormatDocComment(type.GetDocumentationCommentXml(), "")); s.AppendLine("#[derive(Clone, Debug, Deserialize, Serialize)]"); s.Append($"pub enum {type.Name} {{"); var display = new StringBuilder(); display.AppendLine($"impl fmt::Display for {type.Name} {{"); display.AppendLine(" fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {"); display.AppendLine(" match *self {"); var fields = type.GetMembers() .OfType() .Where((f) => f.HasConstantValue) .ToArray(); var maxFieldNameLength = fields.Length == 0 ? 0 : fields.Select((p) => p.Name.Length).Max(); foreach (var field in fields) { s.AppendLine(); s.Append(FormatDocComment(field.GetDocumentationCommentXml(), " ")); if (GetObsoleteAttribute(field) != null) { s.AppendLine($" {GetRustDeprecatedAttribute(field)}"); } var alignment = new string(' ', maxFieldNameLength - field.Name.Length); var value = type.BaseType?.Name == "Enum" ? field.Name : field.ConstantValue; s.AppendLine($" {field.Name},"); display.AppendLine($" {type.Name}::{field.Name} => write!(f, \"{field.Name}\"),"); } s.AppendLine("}"); display.AppendLine(" }"); display.AppendLine(" }"); display.AppendLine("}"); s.AppendLine(); s.Append(display); } private void WriteStaticClassContract( StringBuilder s, ITypeSymbol type, SortedSet imports) { s.Append(FormatDocComment(type.GetDocumentationCommentXml(), "")); var fields = type.GetMembers() .OfType() .Where((f) => f.HasConstantValue) .ToArray(); foreach (var field in fields .Where((f) => f.IsConst && f.DeclaredAccessibility == Accessibility.Public)) { string? memberExpression = null; if (field.ConstantValue is string stringValue) { memberExpression = $"&str = r#\"{stringValue}\"#"; } else if (field.ConstantValue is int) { memberExpression = $"i32 = {field.ConstantValue}"; } if (memberExpression != null) { // The type name prefix can be long and redundant. Skip it for certain classes // that have sufficiently disticnt member names. var prefix = type.Name switch { "TunnelConstraints" or "TunnelHeaderNames" => string.Empty, _ => type.Name, }; s.AppendLine(); s.Append(FormatDocComment(field.GetDocumentationCommentXml(), "")); if (GetObsoleteAttribute(field) != null) { s.AppendLine(GetRustDeprecatedAttribute(field)); } var rsName = ToSnakeCase($"{prefix}{field.Name}").ToUpperInvariant(); s.AppendLine($"pub const {rsName}: {memberExpression};"); } } } private static string ToSnakeCase(string name) { var s = new StringBuilder(name); for (int i = 0; i < s.Length; i++) { if (char.IsUpper(s[i])) { if (i > 0) { s.Insert(i, '_'); i++; } s[i] = char.ToLowerInvariant(s[i]); } } return s.ToString().Replace("i_p", "ip").Replace("i_d", "id").Replace("git_hub", "github"); } private string FormatDocComment(string? comment, string prefix) { if (comment == null) { return string.Empty; } comment = comment.Replace("\r", ""); comment = new Regex("\n *").Replace(comment, " "); comment = new Regex($"") .Replace(comment, (m) => $"`{m.Groups[2].Value}.{m.Groups[3].Value}`"); comment = new Regex($"") .Replace(comment, "`$2`"); var summary = new Regex("(.*)").Match(comment).Groups[1].Value.Trim(); var remarks = new Regex("(.*)").Match(comment).Groups[1].Value.Trim(); var s = new StringBuilder(); foreach (var commentLine in WrapComment(summary, 90 - 3 - prefix.Length)) { s.AppendLine(prefix + "// " + commentLine); } if (!string.IsNullOrEmpty(remarks)) { s.AppendLine(prefix + "//"); foreach (var commentLine in WrapComment(remarks, 90 - 3 - prefix.Length)) { s.AppendLine(prefix + "// " + commentLine); } } return s.ToString(); } private void AppendStructProperty(ITypeSymbol parentType, IPropertySymbol property, SortedSet imports, StringBuilder s) { var csType = property.Type.ToString(); var isNullable = csType.EndsWith("?"); if (isNullable) { csType = csType.Substring(0, csType.Length - 1); } // Detect JsonIgnoreCondition.WhenWritingDefault var ignoreWhenDefault = property.GetAttributes().Any(ad => ad.AttributeClass?.Name == "JsonIgnoreAttribute" && ad.NamedArguments.Any(arg => arg.Value.Value is int i && i == 2)); var serdeDeclarations = new List(); if (property.TryGetJsonPropertyName(out var jsonPropertyName)) { serdeDeclarations.Add($"rename = \"{jsonPropertyName}\""); } var isArray = csType.EndsWith("[]"); if (isArray) { csType = csType.Substring(0, csType.Length - 2); if (isNullable || ignoreWhenDefault) { serdeDeclarations.Add("skip_serializing_if = \"Vec::is_empty\""); serdeDeclarations.Add("default"); isNullable = false; ignoreWhenDefault = false; } } if (ignoreWhenDefault) { serdeDeclarations.Add("default"); } if (serdeDeclarations.Count > 0) { s.AppendLine($" #[serde({string.Join(", ", serdeDeclarations)})]"); } // todo@connor4312: the service currently returns a non-standard format // for these fields, serialize them as strings until that's fixed. if (property.Name == "LastClientConnectionTime" || property.Name == "LastHostConnectionTime") { csType = "string"; } string rsType; if (csType.StartsWith(this.csNamespace + ".")) { rsType = csType.Substring(csNamespace.Length + 1); if (rsType != parentType.Name) { imports.Add($"crate::contracts::{rsType}"); } else if (!isArray) { // Use box for a recursive type // https://doc.rust-lang.org/book/ch15-01-box.html#enabling-recursive-types-with-boxes // Serde supports boxes, fixed in https://github.com/serde-rs/serde/issues/45 rsType = $"Box<{rsType}>"; } } else { rsType = csType switch { "bool" => "bool", "short" => "i16", "ushort" => "u16", "int" => "i32", "uint" => "u32", "long" => "i64", "ulong" => "u64", "string" => "String", "System.DateTime" => "DateTime", "System.Text.RegularExpressions.Regex" => "regexp.Regexp", "System.Collections.Generic.IDictionary" => "HashMap", "System.Collections.Generic.IDictionary" => "HashMap>", _ => throw new NotSupportedException("Unsupported C# type: " + csType), }; } if (isArray) { rsType = $"Vec<{rsType}>"; } if (isNullable) { rsType = $"Option<{rsType}>"; } if (csType == "System.DateTime") { imports.Add("chrono::{DateTime, Utc}"); } else if (csType.Contains("IDictionary<")) { imports.Add("std::collections::HashMap"); } var propertyName = ToSnakeCase(property.Name); if (propertyName == "type") { propertyName = "kind"; s.AppendLine(" #[serde(rename = \"type\")]"); } s.AppendLine($" pub {propertyName}: {rsType},"); } private static string? GetRustDeprecatedAttribute(ISymbol symbol) { var obsoleteAttribute = GetObsoleteAttribute(symbol); if (obsoleteAttribute != null) { var message = GetObsoleteMessage(obsoleteAttribute); return $"[deprecated({message})]"; } return null; } } dev-tunnels-0.0.25/cs/tools/TunnelsSDK.Generator/TSContractWriter.cs000066400000000000000000000354061450757157500253260ustar00rootroot00000000000000// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. // using Microsoft.CodeAnalysis; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; namespace Microsoft.DevTunnels.Generator; internal class TSContractWriter : ContractWriter { public TSContractWriter(string repoRoot, string csNamespace) : base(repoRoot, csNamespace) { } public override void WriteContract(ITypeSymbol type, ICollection allTypes) { var csFilePath = GetRelativePath(type.Locations.Single().GetLineSpan().Path); var fileName = ToCamelCase(type.Name) + ".ts"; var filePath = GetAbsolutePath(Path.Combine("ts", "src", "contracts", fileName)); var s = new StringBuilder(); s.AppendLine("// Copyright (c) Microsoft Corporation."); s.AppendLine("// Licensed under the MIT license."); s.AppendLine($"// Generated from ../../../{csFilePath}"); s.AppendLine("/* eslint-disable */"); s.AppendLine(); var importsOffset = s.Length; var imports = new SortedSet(); WriteContractType(s, "", type, imports); imports.Remove(type.Name); if (imports.Count > 0) { var importLines = string.Join(Environment.NewLine, imports.Select( (i) => $"import {{ {i} }} from './{ToCamelCase(i!)}';")) + Environment.NewLine + Environment.NewLine; s.Insert(importsOffset, importLines); } File.WriteAllText(filePath, s.ToString()); } private void WriteContractType( StringBuilder s, string indent, ITypeSymbol type, SortedSet imports) { var members = type.GetMembers(); if (type.BaseType?.Name == nameof(Enum) || members.All((m) => m.DeclaredAccessibility != Accessibility.Public || (m is IFieldSymbol field && ((field.IsConst && field.Type.Name == nameof(String)) || field.Name == "All")) || (m is IMethodSymbol method && method.MethodKind == MethodKind.StaticConstructor))) { WriteEnumContract(s, indent, type); } else if (type.IsStatic && members.All((m) => m.IsStatic)) { WriteStaticClassContract(s, indent, type, imports); } else { WriteInterfaceContract(s, indent, type, imports); } var nestedTypes = type.GetTypeMembers() .Where((t) => !ContractsGenerator.ExcludedContractTypes.Contains(t.Name)) .ToArray(); if (nestedTypes.Length > 0) { s.AppendLine(); s.Append($"{indent}namespace {type.Name} {{"); foreach (var nestedType in nestedTypes.Where( (t) => !ContractsGenerator.ExcludedContractTypes.Contains(t.Name))) { s.AppendLine(); WriteContractType(s, indent + " ", nestedType, imports); } s.AppendLine($"{indent}}}"); } } private void WriteInterfaceContract( StringBuilder s, string indent, ITypeSymbol type, SortedSet imports) { var baseTypeName = type.BaseType?.Name; if (baseTypeName == nameof(Object)) { baseTypeName = null; } if (!string.IsNullOrEmpty(baseTypeName)) { imports.Add(baseTypeName!); } s.Append(FormatDocComment(type.GetDocumentationCommentXml(), indent)); var extends = baseTypeName != null ? " extends " + baseTypeName : ""; s.Append($"{indent}export interface {type.Name}{extends} {{"); foreach (var property in type.GetMembers().OfType() .Where((p) => !p.IsStatic)) { s.AppendLine(); s.Append(FormatDocComment(property.GetDocumentationCommentXml(), indent + " ")); var propertyType = property.Type.ToDisplayString(); var isNullable = propertyType.EndsWith("?"); if (isNullable) { propertyType = propertyType.Substring(0, propertyType.Length - 1); } // Make booleans always nullable since undefined is falsy anyway. isNullable |= propertyType == "bool"; var tsName = ToCamelCase(property.Name); if (property.TryGetJsonPropertyName(out var jsonPropertyName)) { tsName = jsonPropertyName!; } var tsType = GetTSTypeForCSType(propertyType, tsName, imports); s.AppendLine($"{indent} {tsName}{(isNullable ? "?" : "")}: {tsType};"); } s.AppendLine($"{indent}}}"); var constMemberNames = new List(); foreach (var field in type.GetMembers().OfType() .Where((f) => f.IsConst)) { if (field.DeclaredAccessibility == Accessibility.Public) { constMemberNames.Add(ToCamelCase(field.Name)); } else if (field.DeclaredAccessibility != Accessibility.Internal) { continue; } s.AppendLine(); s.Append(FormatDocComment(field.GetDocumentationCommentXml(), indent, GetJsDoc(field))); s.AppendLine($"{indent}export const {ToCamelCase(field.Name)} = '{field.ConstantValue}';"); } s.Append(ExportStaticMembers(type, constMemberNames)); } private void WriteEnumContract( StringBuilder s, string indent, ITypeSymbol type) { s.Append(FormatDocComment(type.GetDocumentationCommentXml(), indent)); s.Append($"{indent}export enum {type.Name} {{"); foreach (var member in type.GetMembers()) { if (!(member is IFieldSymbol field) || !field.HasConstantValue || field.DeclaredAccessibility != Accessibility.Public) { continue; } s.AppendLine(); s.Append(FormatDocComment(field.GetDocumentationCommentXml(), indent + " ", GetJsDoc(field))); var value = type.BaseType?.Name == "Enum" ? field.Name : field.ConstantValue; s.AppendLine($"{indent} {field.Name} = '{value}',"); } s.AppendLine($"{indent}}}"); s.Append(ExportStaticMembers(type)); } private void WriteStaticClassContract( StringBuilder s, string indent, ITypeSymbol type, SortedSet imports) { s.Append(FormatDocComment(type.GetDocumentationCommentXml(), indent)); s.Append($"namespace {type.Name} {{"); foreach (var member in type.GetMembers()) { var property = member as IPropertySymbol; var field = member as IFieldSymbol; if (!member.IsStatic || !(property?.IsReadOnly == true || field?.IsConst == true)) { continue; } var memberType = (property?.Type ?? field!.Type).ToDisplayString(); var isNullable = memberType.EndsWith("?"); if (isNullable) { memberType = memberType.Substring(0, memberType.Length - 1); } // Make booleans always nullable since undefined is falsy anyway. isNullable |= memberType == "bool"; var tsName = ToCamelCase(member.Name); var tsType = GetTSTypeForCSType(memberType, tsName, imports); var value = GetMemberInitializer(member); if (value != null) { s.AppendLine(); s.Append(FormatDocComment(member.GetDocumentationCommentXml(), indent + " ", GetJsDoc(member))); s.AppendLine($"{indent} " + $"export const {tsName}: {tsType}{(isNullable ? " | null" : "")} = {value};"); } } s.AppendLine("}"); } private static string ExportStaticMembers( ITypeSymbol type, ICollection? constMemberNames = null) { var s = new StringBuilder(); constMemberNames ??= Array.Empty(); var staticMemberNames = type.GetMembers() .Where((s) => s.IsStatic && s.DeclaredAccessibility == Accessibility.Public && (s is IPropertySymbol p || (s is IMethodSymbol m && m.MethodKind == MethodKind.Ordinary))) .Select((m) => ToCamelCase(m.Name)) .ToArray(); if (staticMemberNames.Length > 0) { s.AppendLine(); s.AppendLine("// Import static members from a non-generated file,"); s.AppendLine("// and re-export them as an object with the same name as the interface."); s.AppendLine("import {"); foreach (var memberName in staticMemberNames) { s.AppendLine($" {memberName},"); } s.AppendLine($"}} from './{ToCamelCase(type.Name)}Statics';"); } if (constMemberNames.Count > 0 || staticMemberNames.Length > 0) { s.AppendLine(); s.AppendLine($"export const {type.Name} = {{"); foreach (var memberName in constMemberNames.Concat(staticMemberNames)) { s.AppendLine($" {memberName},"); } s.AppendLine("};"); } return s.ToString(); } internal static string ToCamelCase(string name) { return name.Substring(0, 1).ToLowerInvariant() + name.Substring(1); } private string FormatDocComment(string? comment, string indent, List? jsdoc = null) { if (comment == null) { return string.Empty; } comment = comment.Replace("\r", ""); comment = new Regex("\n *").Replace(comment, " "); comment = new Regex($"") .Replace(comment, (m) => $"{{@link {m.Groups[2].Value}.{ToCamelCase(m.Groups[3].Value)}}}"); comment = new Regex($"") .Replace(comment, "{@link $2}"); var summary = new Regex("(.*)").Match(comment).Groups[1].Value.Trim(); var remarks = new Regex("(.*)").Match(comment).Groups[1].Value.Trim(); var s = new StringBuilder(); s.AppendLine(indent + "/**"); foreach (var commentLine in WrapComment(summary, 90 - 3 - indent.Length)) { s.AppendLine(indent + " * " + commentLine); } if (!string.IsNullOrEmpty(remarks)) { s.AppendLine(indent + " *"); foreach (var commentLine in WrapComment(remarks, 90 - 3 - indent.Length)) { s.AppendLine(indent + " * " + commentLine); } } if (jsdoc != null) { foreach (var line in jsdoc) { s.AppendLine(indent + " * " + line); } } s.AppendLine(indent + " */"); return s.ToString(); } private static string? GetMemberInitializer(ISymbol member) { var location = member.Locations.Single(); var sourceSpan = location.SourceSpan; var sourceText = location.SourceTree!.ToString(); var eolIndex = sourceText.IndexOf('\n', sourceSpan.End); var equalsIndex = sourceText.IndexOf('=', sourceSpan.End); if (equalsIndex < 0 || equalsIndex > eolIndex) { // The member does not have an initializer. return null; } var semicolonIndex = sourceText.IndexOf(';', equalsIndex); if (semicolonIndex < 0) { // Invalid syntax?? return null; } var csExpression = sourceText.Substring( equalsIndex + 1, semicolonIndex - equalsIndex - 1).Trim(); // Attempt to convert the CS expression to a TS expression. This involves several // weak assumptions, and will not work for many kinds of expressions. But it might // be good enough. var tsExpression = csExpression .Replace("'", "^^^").Replace("\\\"", "$$$").Replace('"', '\'').Replace("$$$", "\"").Replace("^^^", "\\'") .Replace("Regex", "RegExp") .Replace("Replace", "replace"); // Assume any PascalCase identifiers are referncing other variables in scope. tsExpression = new Regex("([A-Z][a-z]+){2,6}\\b(?!\\()").Replace( tsExpression, (m) => { return (member.ContainingType.MemberNames.Contains(m.Value) ? member.ContainingType.Name + "." : string.Empty) + ToCamelCase(m.Value); }); return tsExpression; } private string GetTSTypeForCSType(string csType, string propertyName, SortedSet imports) { var suffix = ""; if (csType.EndsWith("[]")) { suffix = "[]"; csType = csType.Substring(0, csType.Length - 2); } string tsType; if (csType.StartsWith(this.csNamespace + ".")) { tsType = csType.Substring(csNamespace.Length + 1); if (!imports.Contains(tsType)) { imports.Add(tsType); } if (tsType == "ResourceStatus") { tsType = "number | ResourceStatus"; } } else { tsType = csType switch { "bool" => "boolean", "short" => "number", "ushort" => "number", "int" => "number", "uint" => "number", "long" => "number", "ulong" => "number", "string" => "string", "System.DateTime" => "Date", "System.Text.RegularExpressions.Regex" => "RegExp", "System.Collections.Generic.IDictionary" => $"{{ [{(propertyName == "accessTokens" ? "scope" : "key")}: string]: string }}", "System.Collections.Generic.IDictionary" => $"{{ [{(propertyName == "errors" ? "property" : "key")}: string]: string[] }}", _ => throw new NotSupportedException("Unsupported C# type: " + csType), }; } tsType += suffix; return tsType; } private static List GetJsDoc(ISymbol symbol) { var doc = new List(); var obsoleteAttribute = GetObsoleteAttribute(symbol); if (obsoleteAttribute != null) { var message = GetObsoleteMessage(obsoleteAttribute); doc.Add($"@deprecated {message}"); } return doc; } } dev-tunnels-0.0.25/cs/tools/TunnelsSDK.Generator/TunnelsSDK.Generator.csproj000066400000000000000000000014371450757157500267140ustar00rootroot00000000000000 Microsoft.DevTunnels.Generator Microsoft.DevTunnels.Generator netstandard2.0 enable true false all runtime; build; native; contentfiles; analyzers; buildtransitive dev-tunnels-0.0.25/go.mod000066400000000000000000000003511450757157500151670ustar00rootroot00000000000000module github.com/microsoft/dev-tunnels go 1.17 require ( github.com/gorilla/websocket v1.4.2 github.com/rodaine/table v1.0.1 golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064 ) require golang.org/x/sys v0.1.0 // indirect dev-tunnels-0.0.25/go.sum000066400000000000000000000051101450757157500152120ustar00rootroot00000000000000github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rodaine/table v1.0.1 h1:U/VwCnUxlVYxw8+NJiLIuCxA/xa6jL38MY3FYysVWWQ= github.com/rodaine/table v1.0.1/go.mod h1:UVEtfBsflpeEcD56nF4F5AocNFta0ZuolpSVdPtlmP4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064 h1:S25/rfnfsMVgORT4/J61MJ7rdyseOZOyvLIrZEZ7s6s= golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= dev-tunnels-0.0.25/go/000077500000000000000000000000001450757157500144675ustar00rootroot00000000000000dev-tunnels-0.0.25/go/tunnels/000077500000000000000000000000001450757157500161575ustar00rootroot00000000000000dev-tunnels-0.0.25/go/tunnels/PUBLISHING.md000066400000000000000000000005111450757157500201420ustar00rootroot00000000000000# Publishing 1. Update the packageVersion constant in tunnels.go to the new version 2. Tag the new version with `git tag v0.0.X` (replace X with new version number) 3. Push the tag to github with `git push origin v0.0.X` 4. Publish the new version to the go package index `go list -m github.com/microsoft/dev-tunnels@v0.0.X`dev-tunnels-0.0.25/go/tunnels/access_scopes.go000066400000000000000000000024251450757157500213260ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package tunnels import ( "fmt" "strings" ) var ( allScopes = map[TunnelAccessScope]bool{ TunnelAccessScopeManage: true, TunnelAccessScopeManagePorts: true, TunnelAccessScopeHost: true, TunnelAccessScopeInspect: true, TunnelAccessScopeConnect: true, } ) func (s *TunnelAccessScopes) valid(validScopes []TunnelAccessScope, allowMultiple bool) error { if s == nil { return fmt.Errorf("scopes cannot be null") } var scopes TunnelAccessScopes if allowMultiple { for _, scope := range *s { for _, ss := range strings.Split(string(scope), " ") { scopes = append(scopes, TunnelAccessScope(ss)) } } } else { scopes = *s } for _, scope := range scopes { if len(scope) == 0 { return fmt.Errorf("scope cannot be null") } else if !allScopes[scope] { return fmt.Errorf("invalid scope %s", scope) } } if len(validScopes) > 0 { for _, scope := range scopes { if !scopeContains(validScopes, scope) { return fmt.Errorf("tunnel access scope is invalid for current request: %s", scope) } } } return nil } func scopeContains(s []TunnelAccessScope, e TunnelAccessScope) bool { for _, a := range s { if a == e { return true } } return false } dev-tunnels-0.0.25/go/tunnels/buffer.go000066400000000000000000000004371450757157500177630ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package tunnels import "bytes" type buffer struct { bytes.Buffer } // Add a Close method to our buffer so that we satisfy io.ReadWriteCloser. func (b *buffer) Close() error { b.Buffer.Reset() return nil } dev-tunnels-0.0.25/go/tunnels/client.go000066400000000000000000000213101450757157500177610ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package tunnels import ( "context" "errors" "fmt" "io" "log" "net" "strings" "net/http" tunnelssh "github.com/microsoft/dev-tunnels/go/tunnels/ssh" "github.com/microsoft/dev-tunnels/go/tunnels/ssh/messages" "golang.org/x/crypto/ssh" ) const ( clientWebSocketSubProtocol = "tunnel-relay-client" ) // Client is a client for a tunnel. It is used to connect to a tunnel. type Client struct { logger *log.Logger hostID string tunnel *Tunnel endpoints []TunnelEndpoint ssh *tunnelssh.ClientSSHSession remoteForwardedPorts *remoteForwardedPorts acceptLocalConnectionsForForwardedPorts bool } var ( // ErrNoTunnel is returned when no tunnel is provided. ErrNoTunnel = errors.New("tunnel cannot be nil") // ErrNoTunnelEndpoints is returned when no tunnel endpoints are provided. ErrNoTunnelEndpoints = errors.New("tunnel endpoints cannot be nil or empty") // ErrNoConnections is returned when no tunnel endpoints are provided for the given host ID. ErrNoConnections = errors.New("the specified host is not currently accepting connections to the tunnel") // ErrMultipleHosts is returned when multiple tunnel endpoints for different hosts are provided. ErrMultipleHosts = errors.New("there are multiple hosts for the tunnel, specify the host ID to connect to") // ErrNoRelayConnections is returned when no relay connections are available. ErrNoRelayConnections = errors.New("the host is not currently accepting tunnel relay connections") // ErrSSHConnectionClosed is returned when the ssh connection is closed. ErrSSHConnectionClosed = errors.New("the ssh connection is closed") // ErrPortNotForwarded is returned when the specified port is not forwarded. ErrPortNotForwarded = errors.New("the port is not forwarded") ) // Connect connects to a tunnel and returns a connected client. func NewClient(logger *log.Logger, tunnel *Tunnel, acceptLocalConnectionsForForwardedPorts bool) (*Client, error) { if tunnel == nil { return nil, ErrNoTunnel } if len(tunnel.Endpoints) == 0 { return nil, ErrNoTunnelEndpoints } c := &Client{ logger: logger, tunnel: tunnel, endpoints: tunnel.Endpoints, remoteForwardedPorts: newRemoteForwardedPorts(), acceptLocalConnectionsForForwardedPorts: acceptLocalConnectionsForForwardedPorts, } return c, nil } func (c *Client) Connect(ctx context.Context, hostID string) error { endpointGroups := make(map[string][]TunnelEndpoint) for _, endpoint := range c.tunnel.Endpoints { endpointGroups[endpoint.HostID] = append(endpointGroups[endpoint.HostID], endpoint) } var endpointGroup []TunnelEndpoint c.hostID = hostID if hostID != "" { g, ok := endpointGroups[hostID] if !ok { return ErrNoConnections } endpointGroup = g } else if len(endpointGroups) > 1 { return ErrMultipleHosts } else { endpointGroup = endpointGroups[c.tunnel.Endpoints[0].HostID] } if len(c.endpoints) != 1 { return ErrNoRelayConnections } tunnelEndpoint := endpointGroup[0] clientRelayURI := tunnelEndpoint.ClientRelayURI accessToken := c.tunnel.AccessTokens[TunnelAccessScopeConnect] c.logger.Printf(fmt.Sprintf("Connecting to client tunnel relay %s", clientRelayURI)) c.logger.Printf(fmt.Sprintf("Sec-Websocket-Protocol: %s", clientWebSocketSubProtocol)) protocols := []string{clientWebSocketSubProtocol} var headers http.Header if accessToken != "" { headers = make(http.Header) if !strings.Contains(accessToken, "Tunnel") && !strings.Contains(accessToken, "tunnel") { accessToken = fmt.Sprintf("Tunnel %s", accessToken) } headers.Add("Authorization", accessToken) c.logger.Printf(fmt.Sprintf("Authorization: %s", accessToken)) } sock := newSocket(clientRelayURI, protocols, headers, nil) if err := sock.connect(ctx); err != nil { return fmt.Errorf("failed to connect to client relay: %w", err) } c.ssh = tunnelssh.NewClientSSHSession(sock, c.remoteForwardedPorts, c.acceptLocalConnectionsForForwardedPorts, c.logger) if err := c.ssh.Connect(ctx); err != nil { return fmt.Errorf("failed to create ssh session: %w", err) } return nil } // ConnectListenerToForwardedPort opens a stream to a remote port and connects it to a given listener. // // Ensure that the port is already forwarded before calling this function // by calling WaitForForwardedPort. Otherwise, this will return an error. // // Set acceptLocalConnectionsForForwardedPorts to false when creating the client to ensure // TCP listeners are not created for all ports automatically when the client connects. func (c *Client) ConnectListenerToForwardedPort(ctx context.Context, listenerIn net.Listener, port uint16) error { errc := make(chan error, 1) go func() { for { conn, err := listenerIn.Accept() if err != nil { sendError(err, errc) return } go func() { if err := c.ConnectToForwardedPort(ctx, conn, port); err != nil { sendError(err, errc) } }() } }() return awaitError(ctx, errc) } // ConnectToForwardedPort opens a stream to a remote port and connects it to a given connection. // // Ensure that the port is already forwarded before calling this function // by calling WaitForForwardedPort. Otherwise, this will return an error. // // Set acceptLocalConnectionsForForwardedPorts to false when creating the client to ensure // TCP listeners are not created for all ports automatically when the client connects. func (c *Client) ConnectToForwardedPort(ctx context.Context, conn io.ReadWriteCloser, port uint16) error { errc := make(chan error, 1) go func() { if err := c.handleConnection(ctx, conn, port); err != nil { sendError(err, errc) } }() return awaitError(ctx, errc) } // WaitForForwardedPort waits for the specified port to be forwarded. // It is common practice to call this function before ConnectToForwardedPort. func (c *Client) WaitForForwardedPort(ctx context.Context, port uint16) error { // It's already forwarded there's no need to wait. if c.remoteForwardedPorts.hasPort(port) { return nil } for { select { case <-ctx.Done(): return ctx.Err() case n := <-c.remoteForwardedPorts.notify: if n.port == port && n.notificationType == remoteForwardedPortNotificationTypeAdd { return nil } } } } func (c *Client) RefreshPorts(ctx context.Context) error { if c.ssh == nil { return fmt.Errorf("not Connected") } res, _, err := c.ssh.SendSessionRequest("RefreshPorts", true, make([]byte, 0)) if err != nil { return fmt.Errorf("failed to send port refresh message: %w", err) } if !res { return fmt.Errorf("failed to refresh ports: %w", err) } return err } func sendError(err error, errc chan error) { // Use non-blocking send, to avoid goroutines getting // stuck in case of concurrent or sequential errors. select { case errc <- err: default: } } func awaitError(ctx context.Context, errc chan error) error { select { case err := <-errc: return err case <-ctx.Done(): return ctx.Err() } } func (c *Client) handleConnection(ctx context.Context, conn io.ReadWriteCloser, port uint16) (err error) { defer safeClose(conn, &err) channel, err := c.openStreamingChannel(ctx, port) if err != nil { return fmt.Errorf("failed to open streaming channel: %w", err) } // Ideally we would call safeClose again, but (*ssh.channel).Close // appears to have a bug that causes it return io.EOF spuriously // if its peer closed first; see github.com/golang/go/issues/38115. defer func() { closeErr := channel.Close() if err == nil && closeErr != io.EOF { err = closeErr } }() errs := make(chan error, 2) copyConn := func(w io.Writer, r io.Reader) { _, err := io.Copy(w, r) errs <- err } go copyConn(conn, channel) go copyConn(channel, conn) // Wait until context is cancelled or both copies are done. // Discard errors from io.Copy; they should not cause (e.g.) failures. for i := 0; ; { select { case <-ctx.Done(): return ctx.Err() case <-errs: i++ if i == 2 { return nil } } } } func safeClose(c io.Closer, err *error) { if closerErr := c.Close(); *err == nil { *err = closerErr } } func (c *Client) openStreamingChannel(ctx context.Context, port uint16) (ssh.Channel, error) { portForwardChannel := messages.NewPortForwardChannel( c.ssh.NextChannelID(), "127.0.0.1", uint32(port), "", 0, ) data, err := portForwardChannel.Marshal() if err != nil { return nil, fmt.Errorf("failed to marshal port forward channel open message: %w", err) } channel, err := c.ssh.OpenChannel(ctx, portForwardChannel.Type(), data) if err != nil { return nil, fmt.Errorf("failed to open port forward channel: %w", err) } return channel, nil } func (c *Client) Close() error { return c.ssh.Close() } dev-tunnels-0.0.25/go/tunnels/client_test.go000066400000000000000000000151241450757157500210260ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package tunnels import ( "bytes" "context" "errors" "fmt" "io" "log" "net" "os" "strings" "testing" "time" "github.com/microsoft/dev-tunnels/go/tunnels/ssh/messages" tunnelstest "github.com/microsoft/dev-tunnels/go/tunnels/test" ) func TestSuccessfulConnect(t *testing.T) { accessToken := "tunnel access-token" relayServer, err := tunnelstest.NewRelayServer( tunnelstest.WithAccessToken(accessToken), ) if err != nil { t.Fatal(err) } hostURL := strings.Replace(relayServer.URL(), "http://", "ws://", 1) tunnel := Tunnel{ AccessTokens: map[TunnelAccessScope]string{ TunnelAccessScopeConnect: accessToken, }, Endpoints: []TunnelEndpoint{ { HostID: "host1", TunnelRelayTunnelEndpoint: TunnelRelayTunnelEndpoint{ ClientRelayURI: hostURL, }, }, }, } logger := log.New(os.Stdout, "", log.LstdFlags) done := make(chan error) go func() { c, err := NewClient(logger, &tunnel, true) c.Connect(ctx, "") if err != nil { done <- fmt.Errorf("connect failed: %v", err) return } if c == nil { done <- errors.New("nil connection") return } done <- nil }() select { case err := <-relayServer.Err(): t.Errorf("relay server error: %v", err) case err := <-done: if err != nil { t.Errorf(err.Error()) } } } func TestReturnsErrWithInvalidAccessToken(t *testing.T) { accessToken := "access-token" relayServer, err := tunnelstest.NewRelayServer( tunnelstest.WithAccessToken(accessToken), ) if err != nil { t.Fatal(err) } hostURL := strings.Replace(relayServer.URL(), "http://", "ws://", 1) tunnel := Tunnel{ AccessTokens: map[TunnelAccessScope]string{ TunnelAccessScopeConnect: "invalid-access-token", }, Endpoints: []TunnelEndpoint{ { HostID: "host1", TunnelRelayTunnelEndpoint: TunnelRelayTunnelEndpoint{ ClientRelayURI: hostURL, }, }, }, } logger := log.New(os.Stdout, "", log.LstdFlags) c, _ := NewClient(logger, &tunnel, true) err = c.Connect(ctx, "") if err == nil { t.Error("expected error, got nil") } } func TestReturnsErrWhenTunnelIsNil(t *testing.T) { logger := log.New(os.Stdout, "", log.LstdFlags) _, err := NewClient(logger, nil, true) if err == nil { t.Error("expected error, got nil") } } func TestReturnsErrWhenEndpointsAreNil(t *testing.T) { logger := log.New(os.Stdout, "", log.LstdFlags) tunnel := Tunnel{} _, err := NewClient(logger, &tunnel, true) if err == nil { t.Error("expected error, got nil") } } func TestReturnsErrWhenTunnelEndpointsDontMatchHostID(t *testing.T) { tunnel := Tunnel{ Endpoints: []TunnelEndpoint{ { HostID: "host1", }, }, } logger := log.New(os.Stdout, "", log.LstdFlags) c, _ := NewClient(logger, &tunnel, true) err := c.Connect(ctx, "host2") if err == nil { t.Error("expected error, got nil") } } func TestReturnsErrWhenEndpointGroupsContainMultipleHosts(t *testing.T) { tunnel := Tunnel{ Endpoints: []TunnelEndpoint{ { HostID: "host1", }, { HostID: "host2", }, }, } logger := log.New(os.Stdout, "", log.LstdFlags) c, _ := NewClient(logger, &tunnel, true) err := c.Connect(ctx, "host1") if err == nil { t.Error("expected error, got nil") } } func TestReturnsErrWhenThereAreMoreThanOneEndpoints(t *testing.T) { tunnel := Tunnel{ Endpoints: []TunnelEndpoint{ { HostID: "host1", }, { HostID: "host1", }, }, } logger := log.New(os.Stdout, "", log.LstdFlags) c, _ := NewClient(logger, &tunnel, true) err := c.Connect(ctx, "") if err == nil { t.Error("expected error, got nil") } } func TestPortForwarding(t *testing.T) { addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:8000") if err != nil { t.Fatal(fmt.Errorf("failed to listen: %v", err)) } listen, err := net.ListenTCP("tcp", addr) if err != nil { t.Fatal(fmt.Errorf("failed to listen: %v", err)) } defer listen.Close() ctx, cancel := context.WithCancel(context.Background()) defer cancel() streamPort := uint16(8001) streamData := "stream-data" stream := bytes.NewBufferString(streamData) pfsChannel := messages.NewPortForwardChannel(1, "127.0.0.1", uint32(streamPort), "", 0) relayServer, err := tunnelstest.NewRelayServer( tunnelstest.WithForwardedStream(pfsChannel, streamPort, stream), ) if err != nil { t.Fatal(err) } hostURL := strings.Replace(relayServer.URL(), "http://", "ws://", 1) tunnel := Tunnel{ Endpoints: []TunnelEndpoint{ { HostID: "host1", TunnelRelayTunnelEndpoint: TunnelRelayTunnelEndpoint{ ClientRelayURI: hostURL, }, }, }, } ctx, cancel = context.WithTimeout(ctx, 5*time.Second) defer cancel() logger := log.New(os.Stdout, "", log.LstdFlags) done := make(chan error) go func() { c, err := NewClient(logger, &tunnel, true) c.Connect(ctx, "") if err != nil { done <- fmt.Errorf("connect failed: %v", err) return } if c == nil { done <- errors.New("nil connection") return } if err := relayServer.ForwardPort(ctx, streamPort); err != nil { done <- fmt.Errorf("forward port failed: %v", err) return } if err := c.WaitForForwardedPort(ctx, streamPort); err != nil { done <- fmt.Errorf("wait for forwarded port failed: %v", err) return } // Test connecting with a listener err = c.ConnectListenerToForwardedPort(ctx, listen, streamPort) if err != nil { done <- fmt.Errorf("connect to forwarded port failed: %v", err) return } // Connect to the listener and and test connecting with the given connection conn, err := listen.Accept() if err != nil { done <- fmt.Errorf("accept connection failed: %v", err) return } err = c.ConnectToForwardedPort(ctx, conn, streamPort) if err != nil { done <- fmt.Errorf("connect to forwarded port failed: %v", err) return } done <- nil }() go func() { var conn net.Conn retries := 0 for conn == nil && retries < 2 { conn, err = net.DialTimeout("tcp", ":8000", 2*time.Second) time.Sleep(1 * time.Second) } if conn == nil { done <- errors.New("failed to connect to forwarded port") } b := make([]byte, len(streamData)) if _, err := conn.Read(b); err != nil && err != io.EOF { done <- fmt.Errorf("reading stream: %w", err) } if string(b) != streamData { done <- fmt.Errorf("stream data is not expected value, got: %s", string(b)) } if _, err := conn.Write([]byte("new-data")); err != nil { done <- fmt.Errorf("writing to stream: %w", err) } done <- nil }() select { case <-ctx.Done(): t.Fatal("test timed out") case err := <-relayServer.Err(): t.Errorf("relay server error: %v", err) case err := <-done: if err != nil { t.Errorf(err.Error()) } } } dev-tunnels-0.0.25/go/tunnels/cluster_details.go000066400000000000000000000012141450757157500216720ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/ClusterDetails.cs package tunnels // Details of a tunneling service cluster. Each cluster represents an instance of the // tunneling service running in a particular Azure region. New tunnels are created in the // current region unless otherwise specified. type ClusterDetails struct { // A cluster identifier based on its region. ClusterID string `json:"clusterId"` // The URI of the service cluster. URI string `json:"uri"` // The Azure location of the cluster. AzureLocation string `json:"azureLocation"` } dev-tunnels-0.0.25/go/tunnels/error_codes.go000066400000000000000000000010671450757157500210200ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/ErrorCodes.cs package tunnels // Error codes for ErrorDetail.Code and `x-ms-error-code` header. type ErrorCodes []ErrorCode type ErrorCode string const ( // Operation timed out. ErrorCodeTimeout ErrorCode = "Timeout" // Operation cannot be performed because the service is not available. ErrorCodeServiceUnavailable ErrorCode = "ServiceUnavailable" // Internal error. ErrorCodeInternalError ErrorCode = "InternalError" ) dev-tunnels-0.0.25/go/tunnels/error_detail.go000066400000000000000000000014661450757157500211700ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/ErrorDetail.cs package tunnels // The top-level error object whose code matches the x-ms-error-code response header type ErrorDetail struct { // One of a server-defined set of error codes defined in `ErrorCodes`. Code string `json:"code"` // A human-readable representation of the error. Message string `json:"message"` // The target of the error. Target string `json:"target,omitempty"` // An array of details about specific errors that led to this reported error. Details []ErrorDetail `json:"details,omitempty"` // An object containing more specific information than the current object about the // error. InnerError *InnerErrorDetail `json:"innererror,omitempty"` } dev-tunnels-0.0.25/go/tunnels/examples/000077500000000000000000000000001450757157500177755ustar00rootroot00000000000000dev-tunnels-0.0.25/go/tunnels/examples/example.go000066400000000000000000000044601450757157500217630ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package main import ( "context" "errors" "fmt" "log" "net/url" "os" tunnels "github.com/microsoft/dev-tunnels/go/tunnels" ) // Set the tunnelId and cluster Id for the tunnels you want to connect to const ( tunnelId = "" clusterId = "usw2" ) var ( uri = tunnels.ServiceProperties.ServiceURI userAgent = []tunnels.UserAgent{{Name: "Tunnels-Go-SDK-Example", Version: "0.0.1"}} ctx = context.Background() ) // Put your tunnels access token in the return statement or set the TUNNELS_TOKEN env variable func getAccessToken() string { if token := os.Getenv("TUNNELS_TOKEN"); token != "" { return token } return "" } func main() { logger := log.New(os.Stdout, "", log.LstdFlags) url, err := url.Parse(uri) if err != nil { fmt.Println(fmt.Errorf(err.Error())) return } // create manager to get tunnel managementClient, err := tunnels.NewManager(userAgent, getAccessToken, url, nil) if err != nil { fmt.Println(fmt.Errorf(err.Error())) return } limits, err := managementClient.ListUserLimits(ctx) if err != nil { fmt.Println(fmt.Errorf(err.Error())) return } logger.Printf("Successfully retrieved %d user limit(s)", len(limits)) // set up options to request a connect token options := &tunnels.TunnelRequestOptions{IncludePorts: true, TokenScopes: []tunnels.TunnelAccessScope{"connect"}} newTunnel := &tunnels.Tunnel{ TunnelID: tunnelId, ClusterID: clusterId, } // get tunnel for connection getTunnel, err := managementClient.GetTunnel(ctx, newTunnel, options) if err != nil { fmt.Println(fmt.Errorf(err.Error())) return } if getTunnel.TunnelID == "" { fmt.Println(fmt.Errorf(err.Error())) return } else { logger.Printf(fmt.Sprintf("Got tunnel with id %s", getTunnel.TunnelID)) } // create channels for errors and listeners done := make(chan error) go func() { // start client connection to tunnel c, err := tunnels.NewClient(logger, getTunnel, true) c.Connect(ctx, "") if err != nil { done <- fmt.Errorf("connect failed: %v", err) return } if c == nil { done <- errors.New("nil connection") return } }() for { select { case err := <-done: if err != nil { fmt.Println(fmt.Errorf(err.Error())) } return case <-ctx.Done(): return } } } dev-tunnels-0.0.25/go/tunnels/examples/getting_started.md000066400000000000000000000010151450757157500235030ustar00rootroot00000000000000# Getting Started To use the example you must do the following setup first: 1. Create a tunnel on the CLI or another SDK and put the tunnelId and clusterId in the constants section of example.go 2. Create ports on the tunnel that you want to be hosted 3. Get a tunnels access token and paste it in the return value of getAccessToken() in example.go or set it as the TUNNELS_TOKEN environment variable 4. Start hosting the tunnel either on the CLI or on a different SDK 5. Run example.go with the command `go run example.go`dev-tunnels-0.0.25/go/tunnels/inner_error_detail.go000066400000000000000000000011411450757157500223510ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/InnerErrorDetail.cs package tunnels // An object containing more specific information than the current object about the error. type InnerErrorDetail struct { // A more specific error code than was provided by the containing error. One of a // server-defined set of error codes in `ErrorCodes`. Code string `json:"code"` // An object containing more specific information than the current object about the // error. InnerError *InnerErrorDetail `json:"innererror,omitempty"` } dev-tunnels-0.0.25/go/tunnels/manager.go000066400000000000000000000575071450757157500201360ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package tunnels import ( "bytes" "context" "crypto/tls" "encoding/json" "fmt" "io" "net/http" "net/url" "reflect" "strings" ) var ServiceProperties = TunnelServiceProperties{ ServiceURI: fmt.Sprintf("https://%s/", prodDnsName), ServiceAppID: prodFirstPartyAppID, ServiceInternalAppID: prodThirdPartyAppID, GitHubAppClientID: prodGitHubAppClientID, } var PpeServiceProperties = TunnelServiceProperties{ ServiceURI: fmt.Sprintf("https://%s/", ppeDnsName), ServiceAppID: nonProdFirstPartyAppID, ServiceInternalAppID: ppeThirdPartyAppID, GitHubAppClientID: nonProdGitHubAppClientID, } var DevServiceProperties = TunnelServiceProperties{ ServiceURI: fmt.Sprintf("https://%s/", devDnsName), ServiceAppID: nonProdFirstPartyAppID, ServiceInternalAppID: devThirdPartyAppID, GitHubAppClientID: nonProdGitHubAppClientID, } type tokenProviderfn func() string const ( apiV1Path = "/api/v1" tunnelsApiPath = apiV1Path + "/tunnels" userLimitsApiPath = apiV1Path + "/userlimits" subjectsApiPath = apiV1Path + "/subjects" clustersApiPath = apiV1Path + "/clusters" checkNameAvailabilityPath = "/checkNameAvailability" endpointsApiSubPath = "/endpoints" portsApiSubPath = "/ports" tunnelAuthenticationScheme = "Tunnel" goUserAgent = "Dev-Tunnels-Service-Go-SDK/" + PackageVersion ) var ( defaultServiceUrl = ServiceProperties.ServiceURI ) var ( manageAccessTokenScope = []TunnelAccessScope{TunnelAccessScopeManage} hostAccessTokenScope = []TunnelAccessScope{TunnelAccessScopeHost} managePortsAccessTokenScopes = []TunnelAccessScope{TunnelAccessScopeManage, TunnelAccessScopeManagePorts, TunnelAccessScopeHost} readAccessTokenScopes = []TunnelAccessScope{TunnelAccessScopeManage, TunnelAccessScopeManagePorts, TunnelAccessScopeHost, TunnelAccessScopeConnect} ) // UserAgent contains the name and version of the client. type UserAgent struct { Name string Version string } // Manager is used to interact with the Visual Studio Tunnel Service APIs. type Manager struct { tokenProvider tokenProviderfn httpClient *http.Client uri *url.URL additionalHeaders map[string]string userAgents []UserAgent } // Creates a new Manager used for interacting with the Tunnels APIs. // tokenProvider is an optional paramater containing a function that returns the access token to use for the request. // If no tunnelServiceUrl or httpClient is provided, the default values will be used. // Can return error if userAgent is empty or url is invalid. func NewManager(userAgents []UserAgent, tp tokenProviderfn, tunnelServiceUrl *url.URL, httpHandler *http.Client) (*Manager, error) { if len(userAgents) == 0 { return nil, fmt.Errorf("user agents cannot be empty") } if tp == nil { tp = func() string { return "" } } if tunnelServiceUrl == nil { url, err := url.Parse(defaultServiceUrl) if err != nil { return nil, fmt.Errorf("error parsing default url %w", err) } tunnelServiceUrl = url } var client *http.Client if httpHandler == nil { if strings.Contains(tunnelServiceUrl.Host, "localhost") { client = &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} } else { client = &http.Client{} } } else { client = httpHandler } return &Manager{tokenProvider: tp, httpClient: client, uri: tunnelServiceUrl, userAgents: userAgents}, nil } // Lists tunnels owned by the authenticated user. // Returns a list of tunnels or an error if the search fails. func (m *Manager) ListTunnels( ctx context.Context, clusterID string, domain string, options *TunnelRequestOptions, ) (ts []*Tunnel, err error) { queryParams := url.Values{} if clusterID == "" { queryParams.Add("global", "true") } if domain != "" { queryParams.Add("domain", domain) } url := m.buildUri(clusterID, tunnelsApiPath, options, queryParams.Encode()) response, err := m.sendTunnelRequest(ctx, nil, options, http.MethodGet, url, nil, nil, readAccessTokenScopes, false) if err != nil { return nil, fmt.Errorf("error sending list tunnel request: %w", err) } err = json.Unmarshal(response, &ts) if err != nil { return nil, fmt.Errorf("error parsing response json to tunnel: %w", err) } return ts, nil } // Gets a tunnel by id or name. // If getting a tunnel by name the domain must be provided if the tunnel is not in the default domain. // Returns the requested tunnel or an error if the tunnel is not found. func (m *Manager) GetTunnel(ctx context.Context, tunnel *Tunnel, options *TunnelRequestOptions) (t *Tunnel, err error) { url, err := m.buildTunnelSpecificUri(tunnel, "", options, "") if err != nil { return nil, fmt.Errorf("error creating tunnel url: %w", err) } response, err := m.sendTunnelRequest(ctx, tunnel, options, http.MethodGet, url, nil, nil, readAccessTokenScopes, true) if err != nil { return nil, fmt.Errorf("error sending get tunnel request: %w", err) } // Read response into a tunnel err = json.Unmarshal(response, &t) if err != nil { return nil, fmt.Errorf("error parsing response json to tunnel: %w", err) } return t, err } // Creates a new tunnel with the properties specified in tunnel. // Tunnel fields may be nil but the tunnel struct must not be nil. // Returns the created tunnel or an error if the create fails. func (m *Manager) CreateTunnel(ctx context.Context, tunnel *Tunnel, options *TunnelRequestOptions) (t *Tunnel, err error) { if tunnel == nil { return nil, fmt.Errorf("tunnel must be provided") } if tunnel.TunnelID != "" { return nil, fmt.Errorf("tunnelId cannot be set for creating a tunnel") } url := m.buildUri(tunnel.ClusterID, tunnelsApiPath, options, "") convertedTunnel, err := tunnel.requestObject() if err != nil { return nil, fmt.Errorf("error converting tunnel for request: %w", err) } response, err := m.sendTunnelRequest(ctx, tunnel, options, http.MethodPost, url, convertedTunnel, nil, manageAccessTokenScope, false) if err != nil { return nil, fmt.Errorf("error sending create tunnel request: %w", err) } // Read response into a tunnel err = json.Unmarshal(response, &t) if err != nil { return nil, fmt.Errorf("error parsing response json to tunnel: %w", err) } return t, err } // Updates a tunnel's properties, to update a field the field name must be included in updateFields. // Returns the updated tunnel or an error if the update fails. func (m *Manager) UpdateTunnel(ctx context.Context, tunnel *Tunnel, updateFields []string, options *TunnelRequestOptions) (t *Tunnel, err error) { if tunnel == nil { return nil, fmt.Errorf("tunnel must be provided") } url, err := m.buildTunnelSpecificUri(tunnel, "", options, "") if err != nil { return nil, fmt.Errorf("error creating request url: %w", err) } convertedTunnel, err := tunnel.requestObject() if err != nil { return nil, fmt.Errorf("error converting tunnel for request: %w", err) } response, err := m.sendTunnelRequest(ctx, tunnel, options, http.MethodPut, url, convertedTunnel, updateFields, manageAccessTokenScope, false) if err != nil { return nil, fmt.Errorf("error sending update tunnel request: %w", err) } // Read response into a tunnel err = json.Unmarshal(response, &t) if err != nil { return nil, fmt.Errorf("error parsing response json to tunnel: %w", err) } return t, err } // Deletes a tunnel. // Returns error if delete fails. func (m *Manager) DeleteTunnel(ctx context.Context, tunnel *Tunnel, options *TunnelRequestOptions) error { url, err := m.buildTunnelSpecificUri(tunnel, "", options, "") if err != nil { return fmt.Errorf("error creating tunnel url: %w", err) } _, err = m.sendTunnelRequest(ctx, tunnel, options, http.MethodDelete, url, nil, nil, manageAccessTokenScope, true) if err != nil { return fmt.Errorf("error sending delete tunnel request: %w", err) } return nil } // Updates an endpoint on a tunnel. // Returns the updated endpoint or an error if the update fails. func (m *Manager) UpdateTunnelEndpoint( ctx context.Context, tunnel *Tunnel, endpoint *TunnelEndpoint, updateFields []string, options *TunnelRequestOptions, ) (te *TunnelEndpoint, err error) { if endpoint == nil { return nil, fmt.Errorf("endpoint must be provided and must not be nil") } if endpoint.HostID == "" { return nil, fmt.Errorf("endpoint hostId must be provided and must not be nil") } url, err := m.buildTunnelSpecificUri(tunnel, fmt.Sprintf("%s/%s/%s", endpointsApiSubPath, endpoint.HostID, endpoint.ConnectionMode), options, "") if err != nil { return nil, fmt.Errorf("error creating tunnel url: %w", err) } response, err := m.sendTunnelRequest(ctx, tunnel, options, http.MethodPut, url, endpoint, updateFields, hostAccessTokenScope, false) if err != nil { return nil, fmt.Errorf("error sending update tunnel endpoint request: %w", err) } // Read response into a tunnel endpoint err = json.Unmarshal(response, &te) if err != nil { return nil, fmt.Errorf("error parsing response json to tunnel: %w", err) } var newEndpoints []TunnelEndpoint for _, ep := range tunnel.Endpoints { if ep.HostID != endpoint.HostID || ep.ConnectionMode != endpoint.ConnectionMode { newEndpoints = append(newEndpoints, ep) } } newEndpoints = append(newEndpoints, *te) tunnel.Endpoints = newEndpoints return te, err } // Deletes endpoints on a tunnel. // Returns error if the delete fails. func (m *Manager) DeleteTunnelEndpoints( ctx context.Context, tunnel *Tunnel, hostID string, connectionMode TunnelConnectionMode, options *TunnelRequestOptions, ) error { if hostID == "" { return fmt.Errorf("hostId must be provided and must not be nil") } path := fmt.Sprintf("%s/%s/%s", endpointsApiSubPath, hostID, connectionMode) if connectionMode == "" { path = fmt.Sprintf("%s/%s", endpointsApiSubPath, hostID) } url, err := m.buildTunnelSpecificUri(tunnel, path, options, "") if err != nil { return fmt.Errorf("error creating tunnel url: %w", err) } _, err = m.sendTunnelRequest(ctx, tunnel, options, http.MethodDelete, url, nil, nil, hostAccessTokenScope, true) if err != nil { return fmt.Errorf("error sending delete tunnel endpoint request: %w", err) } var newEndpoints []TunnelEndpoint for _, ep := range tunnel.Endpoints { if ep.HostID != hostID || ep.ConnectionMode != connectionMode { newEndpoints = append(newEndpoints, ep) } } tunnel.Endpoints = newEndpoints return err } // Lists ports on the tunnel. func (m *Manager) ListTunnelPorts( ctx context.Context, tunnel *Tunnel, options *TunnelRequestOptions, ) (tp []*TunnelPort, err error) { url, err := m.buildTunnelSpecificUri(tunnel, portsApiSubPath, options, "") if err != nil { return nil, fmt.Errorf("error creating tunnel url: %w", err) } response, err := m.sendTunnelRequest(ctx, tunnel, options, http.MethodGet, url, nil, nil, readAccessTokenScopes, false) if err != nil { return nil, fmt.Errorf("error sending list tunnel ports request: %w", err) } // Read response into a tunnel port err = json.Unmarshal(response, &tp) if err != nil { return nil, fmt.Errorf("error parsing response json to tunnel ports: %w", err) } return tp, nil } func (m *Manager) GetTunnelPort( ctx context.Context, tunnel *Tunnel, port int, options *TunnelRequestOptions, ) (tp *TunnelPort, err error) { url, err := m.buildTunnelSpecificUri(tunnel, fmt.Sprintf("%s/%d", portsApiSubPath, port), options, "") if err != nil { return nil, fmt.Errorf("error creating tunnel url: %w", err) } response, err := m.sendTunnelRequest(ctx, tunnel, options, http.MethodGet, url, nil, nil, readAccessTokenScopes, true) if err != nil { return nil, fmt.Errorf("error sending get tunnel port request: %w", err) } // Read response into a tunnel port err = json.Unmarshal(response, &tp) if err != nil { return nil, fmt.Errorf("error parsing response json to tunnel ports: %w", err) } return tp, nil } // Creates a port on the tunnel. // Returns the created port or error if create fails. func (m *Manager) CreateTunnelPort( ctx context.Context, tunnel *Tunnel, port *TunnelPort, options *TunnelRequestOptions, ) (tp *TunnelPort, err error) { url, err := m.buildTunnelSpecificUri(tunnel, portsApiSubPath, options, "") if err != nil { return nil, fmt.Errorf("error creating tunnel url: %w", err) } convertedPort, err := port.requestObject(tunnel) if err != nil { return nil, fmt.Errorf("error converting port for request: %w", err) } response, err := m.sendTunnelRequest(ctx, tunnel, options, http.MethodPost, url, convertedPort, nil, managePortsAccessTokenScopes, true) if err != nil { return nil, fmt.Errorf("error sending create tunnel port request: %w", err) } // Read response into a tunnel port err = json.Unmarshal(response, &tp) if err != nil { return nil, fmt.Errorf("error parsing response json to tunnel port: %w", err) } // Updated local tunnel ports var newPorts []TunnelPort for _, p := range tunnel.Ports { if p.PortNumber != tp.PortNumber { newPorts = append(newPorts, p) } } newPorts = append(newPorts, *tp) tunnel.Ports = newPorts return tp, nil } // Updates a tunnel port. // Returns the updated port or an error if the update fails. func (m *Manager) UpdateTunnelPort( ctx context.Context, tunnel *Tunnel, port *TunnelPort, updateFields []string, options *TunnelRequestOptions, ) (tp *TunnelPort, err error) { if port.ClusterID != "" && tunnel.ClusterID != "" && port.ClusterID != tunnel.ClusterID { return nil, fmt.Errorf("cluster ids do not match") } path := fmt.Sprintf("%s/%d", portsApiSubPath, port.PortNumber) url, err := m.buildTunnelSpecificUri(tunnel, path, options, "") if err != nil { return nil, fmt.Errorf("error creating tunnel url: %w", err) } convertedPort, err := port.requestObject(tunnel) if err != nil { return nil, fmt.Errorf("error converting port for request: %w", err) } response, err := m.sendTunnelRequest(ctx, tunnel, options, http.MethodPut, url, convertedPort, updateFields, managePortsAccessTokenScopes, true) if err != nil { return nil, fmt.Errorf("error sending update tunnel port request: %w", err) } // Read response into a tunnel port err = json.Unmarshal(response, &tp) if err != nil { return nil, fmt.Errorf("error parsing response json to tunnel port: %w", err) } // Updated local tunnel ports var newPorts []TunnelPort for _, p := range tunnel.Ports { if p.PortNumber != tp.PortNumber { newPorts = append(newPorts, p) } } newPorts = append(newPorts, *tp) tunnel.Ports = newPorts return tp, nil } // Deletes a tunnel port. // Returns error if the delete fails. func (m *Manager) DeleteTunnelPort( ctx context.Context, tunnel *Tunnel, port uint16, options *TunnelRequestOptions, ) error { path := fmt.Sprintf("%s/%d", portsApiSubPath, port) url, err := m.buildTunnelSpecificUri(tunnel, path, options, "") if err != nil { return fmt.Errorf("error creating tunnel url: %w", err) } _, err = m.sendTunnelRequest(ctx, tunnel, options, http.MethodDelete, url, nil, nil, managePortsAccessTokenScopes, true) if err != nil { return fmt.Errorf("error sending get tunnel request: %w", err) } // Updated local tunnel ports var newPorts []TunnelPort for _, p := range tunnel.Ports { if p.PortNumber != port { newPorts = append(newPorts, p) } } tunnel.Ports = newPorts return nil } // Lists user limits. // Returns error if the list fails. func (m *Manager) ListUserLimits(ctx context.Context) (limits []*NamedRateStatus, err error) { url := m.buildUri("", userLimitsApiPath, nil, "") token := m.tokenProvider() response, err := m.sendRequest(ctx, http.MethodGet, url, nil, nil, token, false) if err != nil { return nil, fmt.Errorf("error getting user limits: %w", err) } err = json.Unmarshal(response, &limits) if err != nil { return nil, fmt.Errorf("error parsing response json to NamedRateStatus: %w", err) } return limits, nil } func (m *Manager) ListClusters(ctx context.Context) (clusters []*ClusterDetails, err error) { url := m.buildUri("", clustersApiPath, nil, "") response, err := m.sendRequest(ctx, http.MethodGet, url, nil, nil, "", false) if err != nil { return nil, fmt.Errorf("error getting list of clusters: %w", err) } err = json.Unmarshal(response, &clusters) if err != nil { return nil, fmt.Errorf("error parsing response json to ClusterDetails: %w", err) } return clusters, nil } // Checks if tunnel name is available // Returns true if name is available func (m *Manager) CheckNameAvailability( ctx context.Context, name string, ) (res bool, err error) { name = url.QueryEscape(name) path := fmt.Sprintf("%s/%s/%s", tunnelsApiPath, name, checkNameAvailabilityPath) url := m.buildUri("", path, nil, "") response, err := m.sendRequest(ctx, http.MethodGet, url, nil, nil, "", false) if err != nil { return false, fmt.Errorf("error sending list tunnel request: %w", err) } err = json.Unmarshal(response, &res) if err != nil { return false, fmt.Errorf("error parsing response json to bool: %w", err) } return res, nil } func (m *Manager) sendTunnelRequest( ctx context.Context, tunnel *Tunnel, tunnelRequestOptions *TunnelRequestOptions, method string, uri *url.URL, requestObject interface{}, partialFields []string, accessTokenScopes []TunnelAccessScope, allowNotFound bool, ) ([]byte, error) { authHeaderValue := m.getAccessToken(tunnel, tunnelRequestOptions, accessTokenScopes) return m.sendRequest(ctx, method, uri, requestObject, partialFields, authHeaderValue, allowNotFound) } func (m *Manager) sendRequest( ctx context.Context, method string, uri *url.URL, requestObject interface{}, partialFields []string, authHeaderValue string, allowNotFound bool, ) ([]byte, error) { request, err := m.createRequest(ctx, method, uri, requestObject, partialFields) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } // Add authorization header if authHeaderValue != "" { request.Header.Add("Authorization", authHeaderValue) } // Add user agent header userAgentString := "" for _, userAgent := range m.userAgents { if len(userAgent.Version) == 0 { userAgent.Version = "unknown" } if len(userAgent.Name) == 0 { return nil, fmt.Errorf("userAgent name cannot be empty") } userAgentString = fmt.Sprintf("%s%s/%s ", userAgentString, userAgent.Name, userAgent.Version) } userAgentString = strings.TrimSpace(userAgentString) request.Header.Add("User-Agent", fmt.Sprintf("%s %s", goUserAgent, userAgentString)) request.Header.Add("Content-Type", "application/json;charset=UTF-8") // Add additional headers for header, headerValue := range m.additionalHeaders { request.Header.Add(header, headerValue) } result, err := m.httpClient.Do(request) if err != nil { return nil, fmt.Errorf("error sending request: %w", err) } defer result.Body.Close() // Handle non 200s responses if result.StatusCode > 300 { errorMessage, err := m.readProblemDetails(result) if err == nil && errorMessage != nil { return nil, fmt.Errorf("unsuccessful request, response: %d %s\n\t%s", result.StatusCode, http.StatusText(result.StatusCode), *errorMessage) } else { return nil, fmt.Errorf("unsuccessful request, response: %d: %s", result.StatusCode, http.StatusText(result.StatusCode)) } } return io.ReadAll(result.Body) } func (m *Manager) createRequest( ctx context.Context, method string, uri *url.URL, requestObject interface{}, partialFields []string, ) (*http.Request, error) { if requestObject == nil { return http.NewRequest(method, uri.String(), nil) } requestJson, err := partialMarshal(requestObject, partialFields) if err != nil { return nil, fmt.Errorf("error converting request object to json: %w", err) } return http.NewRequest(method, uri.String(), bytes.NewBuffer(requestJson)) } func (m *Manager) readProblemDetails(response *http.Response) (*string, error) { errorBody, err := io.ReadAll(response.Body) if err != nil { return nil, fmt.Errorf("failed to read response body") } var problemDetails *ProblemDetails err = json.Unmarshal(errorBody, &problemDetails) if err != nil { return nil, fmt.Errorf("failed to unmarshal ProblemDetails") } if problemDetails.Title == "" && problemDetails.Detail == "" { return nil, fmt.Errorf("empty ProblemDetails") } var errorMessage string if problemDetails.Title != "" { errorMessage += problemDetails.Title } if problemDetails.Detail != "" { if len(errorMessage) > 0 { errorMessage += " " } errorMessage += problemDetails.Detail } for errorKey, errorDetail := range problemDetails.Errors { errorMessage += "\n\t" + errorKey + ": " for _, errorDetailMessage := range errorDetail { errorMessage += " " errorMessage += errorDetailMessage } } return &errorMessage, nil } func (m *Manager) getAccessToken(tunnel *Tunnel, tunnelRequestOptions *TunnelRequestOptions, accessTokenScopes []TunnelAccessScope) (token string) { if tunnelRequestOptions.AccessToken != "" { token = fmt.Sprintf("%s %s", tunnelAuthenticationScheme, tunnelRequestOptions.AccessToken) } if token == "" { token = m.tokenProvider() } if token == "" && tunnel != nil { for _, scope := range accessTokenScopes { var accessToken string tokensLoop: for tokenScope, token := range tunnel.AccessTokens { // Each key may be either a single scope or space-delimited list of scopes. var scopes = strings.Split(string(tokenScope), " ") for _, s := range scopes { if s == string(scope) { accessToken = token break tokensLoop } } } if accessToken != "" { token = fmt.Sprintf("%s %s", tunnelAuthenticationScheme, accessToken) break } } } return token } func (m *Manager) buildUri(clusterId string, path string, options *TunnelRequestOptions, query string) *url.URL { baseAddress := m.uri if clusterId != "" { if !strings.HasPrefix(baseAddress.Host, "localhost") && !strings.HasPrefix(baseAddress.Host, clusterId) { // A specific cluster ID was specified (while not running on localhost). // Prepend the cluster ID to the hostname, and optionally strip a global prefix. baseAddress.Host = fmt.Sprintf("%s.%s", clusterId, baseAddress.Host) baseAddress.Host = strings.Replace(baseAddress.Host, "global.", "", 1) } } if options != nil { optionsQuery := options.queryString() if optionsQuery != "" { if query != "" { query = fmt.Sprintf("%s&%s", query, optionsQuery) } else { query = optionsQuery } } } baseAddress.Path = path baseAddress.RawQuery = query return baseAddress } func (m *Manager) buildTunnelSpecificUri(tunnel *Tunnel, path string, options *TunnelRequestOptions, query string) (*url.URL, error) { var tunnelPath string if tunnel == nil { return nil, fmt.Errorf("tunnel cannot be nil to make uri") } switch { case tunnel.ClusterID != "" && tunnel.TunnelID != "": tunnelPath = fmt.Sprintf("%s/%s", tunnelsApiPath, tunnel.TunnelID) case tunnel.Name != "": tunnelPath = fmt.Sprintf("%s/%s", tunnelsApiPath, tunnel.Name) if tunnel.Domain != "" { tunnelPath = fmt.Sprintf("%s/%s.%s", tunnelsApiPath, tunnel.Name, tunnel.Domain) } default: return nil, fmt.Errorf("tunnel must have either a name or cluster id and tunnel id") } return m.buildUri(tunnel.ClusterID, tunnelPath+path, options, query), nil } // The omitempty JSON tags on string fields make it impossible to intentionally supply // empty string values when updating. As a workaround, this method marshals a given // list of fields regardless of whether they are empty. func partialMarshal(value interface{}, fields []string) ([]byte, error) { if len(fields) == 0 { return json.Marshal(value) } reflectValue := reflect.Indirect(reflect.ValueOf(value)) reflectType := reflectValue.Type() m := map[string]interface{}{} for _, name := range fields { field, found := reflectType.FieldByName(name) if !found { return nil, fmt.Errorf("field '%s' not found in type '%s'", name, reflectType.Name()) } jsonKey := strings.Split(field.Tag.Get("json"), ",")[0] value := reflectValue.FieldByIndex(field.Index).Interface() m[jsonKey] = value } return json.Marshal(m) } dev-tunnels-0.0.25/go/tunnels/manager_test.go000066400000000000000000000543131450757157500211650ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package tunnels import ( "context" "encoding/json" "fmt" "log" "math/rand" "net/url" "os" "testing" "time" ) var ( serviceUrl = ServiceProperties.ServiceURI ctx = context.Background() userAgentManagerTest = []UserAgent{{Name: "Tunnels-Go-SDK-Tests/Manager", Version: PackageVersion}} ) func getUserToken() string { // Example: "github " or "Bearer " return "" } // These tests do not automatically run in the PR check github action // beacuse they require authentication. If you want to run these tests // you must first generate a tunnels access token and paste it in the // getUserToken return value. func TestTunnelCreateDelete(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode.") } logger := log.New(os.Stdout, "", log.LstdFlags) url, err := url.Parse(serviceUrl) if err != nil { t.Errorf(err.Error()) } managementClient, err := NewManager(userAgentManagerTest, getUserToken, url, nil) if err != nil { t.Errorf(err.Error()) } tunnel := &Tunnel{} options := &TunnelRequestOptions{} createdTunnel, err := managementClient.CreateTunnel(ctx, tunnel, options) if err != nil { t.Errorf(err.Error()) return } if createdTunnel.TunnelID == "" { t.Errorf("tunnel was not successfully created") } else { logger.Printf("Created tunnel with id %s", createdTunnel.TunnelID) createdTunnel.Table().Print() } err = managementClient.DeleteTunnel(ctx, createdTunnel, options) if err != nil { t.Errorf("tunnel was not successfully deleted") } else { logger.Printf("Deleted tunnel with id %s", createdTunnel.TunnelID) } } func TestListTunnels(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode.") } logger := log.New(os.Stdout, "", log.LstdFlags) url, err := url.Parse(serviceUrl) if err != nil { t.Errorf(err.Error()) } managementClient, err := NewManager(userAgentManagerTest, getUserToken, url, nil) if err != nil { t.Errorf(err.Error()) } tunnel := &Tunnel{} options := &TunnelRequestOptions{} createdTunnel, err := managementClient.CreateTunnel(ctx, tunnel, options) if err != nil { t.Errorf(err.Error()) return } if createdTunnel.TunnelID == "" { t.Errorf("tunnel was not successfully created") } else { logger.Printf("Created tunnel with id %s", createdTunnel.TunnelID) createdTunnel.Table().Print() } var token string if createdTunnel.AccessTokens != nil { token = createdTunnel.AccessTokens["manage"] } else { logger.Printf("Did not get token for created tunnel") } options = &TunnelRequestOptions{ AccessToken: token, } tunnels, err := managementClient.ListTunnels(ctx, "", "", options) if err != nil { t.Errorf(err.Error()) } if len(tunnels) == 0 { t.Errorf("tunnel was not successfully listed") } for _, tunnel := range tunnels { logger.Printf("found tunnel with id %s", tunnel.TunnelID) tunnel.Table().Print() } err = managementClient.DeleteTunnel(ctx, createdTunnel, options) if err != nil { t.Errorf("tunnel was not successfully deleted") } else { logger.Printf("Deleted tunnel with id %s", createdTunnel.TunnelID) } } func TestGetAccessToken(t *testing.T) { url, err := url.Parse(serviceUrl) if err != nil { t.Errorf(err.Error()) } managementClient, err := NewManager(userAgentManagerTest, getUserToken, url, nil) if err != nil { t.Errorf(err.Error()) } tunnel := &Tunnel{ AccessTokens: map[TunnelAccessScope]string{ TunnelAccessScopeConnect: "connect_token", TunnelAccessScopeManage: "manage_token", }, } // Test that the connect scope returns the connect token token := managementClient.getAccessToken(tunnel, &TunnelRequestOptions{}, []TunnelAccessScope{TunnelAccessScopeConnect}) if token != "Tunnel connect_token" { t.Errorf("connect token was not successfully retrieved, got %s", token) } // Test that the manage scope returns the manage token token = managementClient.getAccessToken(tunnel, &TunnelRequestOptions{}, []TunnelAccessScope{TunnelAccessScopeManage}) if token != "Tunnel manage_token" { t.Errorf("manage token was not successfully retrieved, got %s", token) } // Test that when providing multiple scopes (manage:ports, connect, manage), either of the tokens is returned (since maps don't guarantee iteration order) token = managementClient.getAccessToken(tunnel, &TunnelRequestOptions{}, []TunnelAccessScope{TunnelAccessScopeManagePorts, TunnelAccessScopeConnect, TunnelAccessScopeManage}) if token != "Tunnel connect_token" && token != "Tunnel manage_token" { t.Errorf("token was not successfully retrieved, got %s", token) } // Update the tunnel to use a space delimited string for the access token type tunnel = &Tunnel{ AccessTokens: map[TunnelAccessScope]string{ "connect manage": "connect_and_manage_token", }, } // Test that the connect scope returns the token token = managementClient.getAccessToken(tunnel, &TunnelRequestOptions{}, []TunnelAccessScope{TunnelAccessScopeConnect}) if token != "Tunnel connect_and_manage_token" { t.Errorf("token was not successfully retrieved, got %s", token) } // Test that the manage scope returns the token token = managementClient.getAccessToken(tunnel, &TunnelRequestOptions{}, []TunnelAccessScope{TunnelAccessScopeManage}) if token != "Tunnel connect_and_manage_token" { t.Errorf("token was not successfully retrieved, got %s", token) } } func TestTunnelCreateUpdateDelete(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode.") } logger := log.New(os.Stdout, "", log.LstdFlags) url, err := url.Parse(serviceUrl) if err != nil { t.Errorf(err.Error()) } managementClient, err := NewManager(userAgentManagerTest, getUserToken, url, nil) if err != nil { t.Errorf(err.Error()) } tunnel := &Tunnel{} options := &TunnelRequestOptions{} createdTunnel, err := managementClient.CreateTunnel(ctx, tunnel, options) if err != nil { t.Errorf(err.Error()) return } if createdTunnel.TunnelID == "" { t.Errorf("tunnel was not successfully created") } else { logger.Printf("Created tunnel with id %s", createdTunnel.TunnelID) createdTunnel.Table().Print() } s1 := rand.NewSource(time.Now().UnixNano()) r1 := rand.New(s1) generatedName := fmt.Sprintf("test%d", r1.Intn(10000)) createdTunnel.Name = generatedName updatedTunnel, err := managementClient.UpdateTunnel(ctx, createdTunnel, []string{"Name"}, options) if err != nil { t.Errorf("tunnel was not successfully updated: %s", err.Error()) } else if updatedTunnel.Name != generatedName { t.Errorf("tunnel was not successfully updated") } else { logger.Printf("Tunnel updated") updatedTunnel.Table().Print() } err = managementClient.DeleteTunnel(ctx, createdTunnel, options) if err != nil { t.Errorf("tunnel was not successfully deleted") } else { logger.Printf("Deleted tunnel with id %s", createdTunnel.TunnelID) } } func TestTunnelCreateUpdateTwiceDelete(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode.") } logger := log.New(os.Stdout, "", log.LstdFlags) url, err := url.Parse(serviceUrl) if err != nil { t.Errorf(err.Error()) } managementClient, err := NewManager(userAgentManagerTest, getUserToken, url, nil) if err != nil { t.Errorf(err.Error()) } tunnel := &Tunnel{} options := &TunnelRequestOptions{} createdTunnel, err := managementClient.CreateTunnel(ctx, tunnel, options) if err != nil { t.Errorf(err.Error()) return } if createdTunnel.TunnelID == "" { t.Errorf("tunnel was not successfully created") } else { logger.Printf("Created tunnel with id %s", createdTunnel.TunnelID) createdTunnel.Table().Print() } s1 := rand.NewSource(time.Now().UnixNano()) r1 := rand.New(s1) generatedName := fmt.Sprintf("test%d", r1.Intn(10000)) createdTunnel.Name = generatedName updatedTunnel, err := managementClient.UpdateTunnel(ctx, createdTunnel, []string{"Name"}, options) if err != nil { t.Errorf("tunnel was not successfully updated: %s", err.Error()) } else if updatedTunnel.Name != generatedName { t.Errorf("tunnel was not successfully updated") } else { logger.Printf("Tunnel updated") updatedTunnel.Table().Print() } // In the second update we want to update the description without updating the name createdTunnel.Name = "" createdTunnel.Description = "test description" updatedTunnel, err = managementClient.UpdateTunnel(ctx, createdTunnel, []string{"Name", "Description"}, options) if err != nil { t.Errorf("tunnel was not successfully updated: %s", err.Error()) } else if updatedTunnel.Name != generatedName || createdTunnel.Description != "test description" { t.Errorf("tunnel was not successfully updated") } else { logger.Printf("Tunnel updated") updatedTunnel.Table().Print() } err = managementClient.DeleteTunnel(ctx, createdTunnel, options) if err != nil { t.Errorf("tunnel was not successfully deleted") } else { logger.Printf("Deleted tunnel with id %s", createdTunnel.TunnelID) } } func TestTunnelCreateGetDelete(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode.") } logger := log.New(os.Stdout, "", log.LstdFlags) url, err := url.Parse(serviceUrl) if err != nil { t.Errorf(err.Error()) } managementClient, err := NewManager(userAgentManagerTest, getUserToken, url, nil) if err != nil { t.Errorf(err.Error()) } tunnel := &Tunnel{} options := &TunnelRequestOptions{} createdTunnel, err := managementClient.CreateTunnel(ctx, tunnel, options) if err != nil { t.Errorf(err.Error()) return } if createdTunnel.TunnelID == "" { t.Errorf("tunnel was not successfully created") } else { logger.Printf("Created tunnel with id %s", createdTunnel.TunnelID) createdTunnel.Table().Print() } getTunnel, err := managementClient.GetTunnel(ctx, createdTunnel, options) if err != nil { t.Errorf(err.Error()) return } if getTunnel.TunnelID == "" { t.Errorf("tunnel was not successfully found") } else { logger.Printf("Got tunnel with id %s", getTunnel.TunnelID) } err = managementClient.DeleteTunnel(ctx, createdTunnel, options) if err != nil { t.Errorf("tunnel was not successfully deleted") } else { logger.Printf("Deleted tunnel with id %s", getTunnel.TunnelID) } } func TestTunnelAddPort(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode.") } logger := log.New(os.Stdout, "", log.LstdFlags) url, err := url.Parse(serviceUrl) if err != nil { t.Errorf(err.Error()) } managementClient, err := NewManager(userAgentManagerTest, getUserToken, url, nil) if err != nil { t.Errorf(err.Error()) } tunnel := &Tunnel{} options := &TunnelRequestOptions{IncludePorts: true} createdTunnel, err := managementClient.CreateTunnel(ctx, tunnel, options) if err != nil { t.Errorf(err.Error()) return } if createdTunnel.TunnelID == "" { t.Errorf("tunnel was not successfully created") } else { logger.Printf("Created tunnel with id %s", createdTunnel.TunnelID) createdTunnel.Table().Print() } portToAdd := NewTunnelPort(3000, "", "", "auto") port, err := managementClient.CreateTunnelPort(ctx, createdTunnel, portToAdd, options) if err != nil { t.Errorf(err.Error()) return } logger.Printf("Created port: %d", port.PortNumber) port.Table().Print() getTunnel, err := managementClient.GetTunnel(ctx, createdTunnel, options) if err != nil { t.Errorf(err.Error()) return } if getTunnel.TunnelID == "" { t.Errorf("tunnel was not successfully found") } else { logger.Printf("Got tunnel with id %s", getTunnel.TunnelID) getTunnel.Table().Print() } if len(getTunnel.Ports) != 1 { t.Errorf("port was not successfully added to tunnel") } err = managementClient.DeleteTunnel(ctx, createdTunnel, options) if err != nil { t.Errorf("tunnel was not successfully deleted") } else { logger.Printf("Deleted tunnel with id %s", createdTunnel.TunnelID) } } func TestTunnelDeletePort(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode.") } logger := log.New(os.Stdout, "", log.LstdFlags) url, err := url.Parse(serviceUrl) if err != nil { t.Errorf(err.Error()) } managementClient, err := NewManager(userAgentManagerTest, getUserToken, url, nil) if err != nil { t.Errorf(err.Error()) } tunnel := &Tunnel{} options := &TunnelRequestOptions{IncludePorts: true} createdTunnel, err := managementClient.CreateTunnel(ctx, tunnel, options) if err != nil { t.Errorf(err.Error()) return } if createdTunnel.TunnelID == "" { t.Errorf("tunnel was not successfully created") } else { logger.Printf("Created tunnel with id %s", createdTunnel.TunnelID) createdTunnel.Table().Print() } portToAdd := NewTunnelPort(3000, "", "", "auto") port, err := managementClient.CreateTunnelPort(ctx, createdTunnel, portToAdd, options) if err != nil { t.Errorf(err.Error()) return } logger.Printf("Created port: %d", port.PortNumber) port.Table().Print() getTunnel, err := managementClient.GetTunnel(ctx, createdTunnel, options) if err != nil { t.Errorf(err.Error()) return } if getTunnel.TunnelID == "" { t.Errorf("tunnel was not successfully found") } else { logger.Printf("Got tunnel with id %s", getTunnel.TunnelID) getTunnel.Table().Print() } err = managementClient.DeleteTunnelPort(ctx, createdTunnel, 3000, options) if err != nil { t.Errorf(err.Error()) return } logger.Printf("Deleted port: %d", port.PortNumber) getTunnel, err = managementClient.GetTunnel(ctx, createdTunnel, options) if err != nil { t.Errorf(err.Error()) return } if getTunnel.TunnelID == "" { t.Errorf("tunnel was not successfully found") } else { logger.Printf("Got tunnel with id %s", getTunnel.TunnelID) getTunnel.Table().Print() } if len(getTunnel.Ports) != 0 { t.Errorf("port was not successfully deleted") } err = managementClient.DeleteTunnel(ctx, createdTunnel, options) if err != nil { t.Errorf("tunnel was not successfully deleted") } else { logger.Printf("Deleted tunnel with id %s", createdTunnel.TunnelID) } } func TestTunnelUpdatePort(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode.") } logger := log.New(os.Stdout, "", log.LstdFlags) url, err := url.Parse(serviceUrl) if err != nil { t.Errorf(err.Error()) } managementClient, err := NewManager(userAgentManagerTest, getUserToken, url, nil) if err != nil { t.Errorf(err.Error()) } tunnel := &Tunnel{} options := &TunnelRequestOptions{IncludePorts: true, TokenScopes: []TunnelAccessScope{"manage"}} createdTunnel, err := managementClient.CreateTunnel(ctx, tunnel, options) if err != nil { t.Errorf(err.Error()) return } if createdTunnel.TunnelID == "" { t.Errorf("tunnel was not successfully created") } else { logger.Printf("Created tunnel with id %s", createdTunnel.TunnelID) createdTunnel.Table().Print() } portToAdd := NewTunnelPort(3000, "", "", "auto") port, err := managementClient.CreateTunnelPort(ctx, createdTunnel, portToAdd, options) if err != nil { t.Errorf(err.Error()) return } logger.Printf("Created port: %d", port.PortNumber) port.Table().Print() getTunnel, err := managementClient.GetTunnel(ctx, createdTunnel, options) if err != nil { t.Errorf(err.Error()) return } if getTunnel.TunnelID == "" { t.Errorf("tunnel was not successfully found") } else { logger.Printf("Got tunnel with id %s", getTunnel.TunnelID) getTunnel.Table().Print() } accessEntry := TunnelAccessControlEntry{ Type: TunnelAccessControlEntryTypeAnonymous, Subjects: []string{}, Scopes: []string{string(TunnelAccessScopeManage)}, } portToAdd.AccessControl = &TunnelAccessControl{ Entries: make([]TunnelAccessControlEntry, 0), } portToAdd.AccessControl.Entries = append(port.AccessControl.Entries, accessEntry) port, err = managementClient.UpdateTunnelPort(ctx, createdTunnel, portToAdd, nil, options) if err != nil { t.Errorf("port was not successfully updated: %s", err) } else if len(port.AccessControl.Entries) != 1 { t.Errorf("port was not successfully updated") } getTunnel, err = managementClient.GetTunnel(ctx, createdTunnel, options) if err != nil { t.Errorf(err.Error()) return } if getTunnel.TunnelID == "" { t.Errorf("tunnel was not successfully found") } else { logger.Printf("Got tunnel with id %s", getTunnel.TunnelID) getTunnel.Table().Print() } if len(getTunnel.Ports[0].AccessControl.Entries) != 1 { t.Errorf("tunnel port was not successfully updated, access control was not changed") } port, err = managementClient.GetTunnelPort(ctx, createdTunnel, 3000, options) if err != nil { t.Errorf("port get error %s", err.Error()) return } if len(port.AccessControl.Entries) != 1 { t.Errorf("tunnel port was not successfully updated, access control was not changed") } err = managementClient.DeleteTunnel(ctx, createdTunnel, options) if err != nil { t.Errorf("tunnel was not successfully deleted") } else { logger.Printf("Deleted tunnel with id %s", createdTunnel.TunnelID) } } func TestTunnelListPorts(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode.") } logger := log.New(os.Stdout, "", log.LstdFlags) url, err := url.Parse(serviceUrl) if err != nil { t.Errorf(err.Error()) } managementClient, err := NewManager(userAgentManagerTest, getUserToken, url, nil) if err != nil { t.Errorf(err.Error()) } tunnel := &Tunnel{} options := &TunnelRequestOptions{IncludePorts: true} createdTunnel, err := managementClient.CreateTunnel(ctx, tunnel, options) if err != nil { t.Errorf(err.Error()) return } if createdTunnel.TunnelID == "" { t.Errorf("tunnel was not successfully created") } else { logger.Printf("Created tunnel with id %s", createdTunnel.TunnelID) createdTunnel.Table().Print() } portToAdd := NewTunnelPort(3000, "", "", "auto") port, err := managementClient.CreateTunnelPort(ctx, createdTunnel, portToAdd, options) if err != nil { t.Errorf(err.Error()) return } logger.Printf("Created port: %d", port.PortNumber) port.Table().Print() portToAdd = NewTunnelPort(3001, "", "", "auto") port, err = managementClient.CreateTunnelPort(ctx, createdTunnel, portToAdd, options) if err != nil { t.Errorf(err.Error()) return } logger.Printf("Created port: %d", port.PortNumber) port.Table().Print() ports, err := managementClient.ListTunnelPorts(ctx, createdTunnel, options) if err != nil { t.Errorf(err.Error()) return } if len(ports) != 2 { t.Errorf("ports not successfully listed") } for _, port := range ports { logger.Printf("Port: %d", port.PortNumber) port.Table().Print() } getTunnel, err := managementClient.GetTunnel(ctx, createdTunnel, options) if err != nil { t.Errorf(err.Error()) return } if getTunnel.TunnelID == "" { t.Errorf("tunnel was not successfully found") } else { logger.Printf("Got tunnel with id %s", getTunnel.TunnelID) getTunnel.Table().Print() } if len(getTunnel.Ports) != 2 { t.Errorf("port was not successfully added to tunnel") } err = managementClient.DeleteTunnel(ctx, createdTunnel, options) if err != nil { t.Errorf("tunnel was not successfully deleted") } else { logger.Printf("Deleted tunnel with id %s", createdTunnel.TunnelID) } } func TestTunnelEndpoints(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode.") } logger := log.New(os.Stdout, "", log.LstdFlags) url, err := url.Parse(serviceUrl) if err != nil { t.Errorf(err.Error()) } managementClient, err := NewManager(userAgentManagerTest, getUserToken, url, nil) if err != nil { t.Errorf(err.Error()) } tunnel := &Tunnel{} options := &TunnelRequestOptions{ TokenScopes: managePortsAccessTokenScopes, } createdTunnel, err := managementClient.CreateTunnel(ctx, tunnel, options) if err != nil { t.Errorf(err.Error()) return } if createdTunnel.TunnelID == "" { t.Errorf("tunnel was not successfully created") } else { logger.Printf("Created tunnel with id %s", createdTunnel.TunnelID) createdTunnel.Table().Print() } // Create and add endpoint endpoint := &TunnelEndpoint{ HostID: "test", ConnectionMode: TunnelConnectionModeTunnelRelay, } updatedEndpoint, err := managementClient.UpdateTunnelEndpoint(ctx, createdTunnel, endpoint, nil, options) if err != nil { t.Errorf(err.Error()) return } logger.Printf("updated endpoint %s", updatedEndpoint.HostID) getTunnel, err := managementClient.GetTunnel(ctx, createdTunnel, options) if err != nil { t.Errorf(err.Error()) return } if getTunnel.TunnelID == "" { t.Errorf("tunnel was not successfully found") } else { logger.Printf("Got tunnel with id %s", getTunnel.TunnelID) } if len(getTunnel.Endpoints) != 1 { t.Errorf("endpoint was not successfully updated") } err = managementClient.DeleteTunnelEndpoints(ctx, createdTunnel, "test", TunnelConnectionModeTunnelRelay, options) if err != nil { t.Errorf(err.Error()) return } getTunnel, err = managementClient.GetTunnel(ctx, createdTunnel, options) if err != nil { t.Errorf(err.Error()) return } if getTunnel.TunnelID == "" { t.Errorf("tunnel was not successfully found") } else { logger.Printf("Got tunnel with id %s", getTunnel.TunnelID) } if len(getTunnel.Endpoints) != 0 { t.Errorf("endpoint was not successfully deleted") } err = managementClient.DeleteTunnel(ctx, createdTunnel, options) if err != nil { t.Errorf("tunnel was not successfully deleted") } else { logger.Printf("Deleted tunnel with id %s", getTunnel.TunnelID) } } func TestResourceStatusUnmarshal(t *testing.T) { var test1 = []byte("{ \"current\": 3, \"limit\": 10 }") var result1 ResourceStatus var err = json.Unmarshal(test1, &result1) if err != nil { t.Error(err) } if result1.Limit == 0 { t.Errorf("Limit was not deserialized") } var result2 ResourceStatus var test2 = []byte("3") err = json.Unmarshal(test2, &result2) if err != nil { t.Error(err) } if result1.Current != result2.Current { t.Errorf("%d != %d", result1.Current, result2.Current) } } func TestValidTokenScopes(t *testing.T) { var validScopes = TunnelAccessScopes{"host", "connect"} var invalidScopes = TunnelAccessScopes{"invalid", "connect"} var multiScopes = TunnelAccessScopes{"host connect", "manage"} if err := validScopes.valid(nil, false); err != nil { t.Error(err) } if err := invalidScopes.valid(nil, false); err == nil { t.Errorf("Invalid scopes should not be valid") } if err := multiScopes.valid(nil, true); err != nil { t.Error(err) } if err := multiScopes.valid(nil, false); err == nil { t.Errorf("Multiple scopes should not be valid without allowMultiple flag") } } dev-tunnels-0.0.25/go/tunnels/problem_details.go000066400000000000000000000015651450757157500216620ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/ProblemDetails.cs package tunnels // Structure of error details returned by the tunnel service, including validation errors. // // This object may be returned with a response status code of 400 (or other 4xx code). It // is compatible with RFC 7807 Problem Details (https://tools.ietf.org/html/rfc7807) and // https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.problemdetails but // doesn't require adding a dependency on that package. type ProblemDetails struct { // Gets or sets the error title. Title string `json:"title,omitempty"` // Gets or sets the error detail. Detail string `json:"detail,omitempty"` // Gets or sets additional details about individual request properties. Errors map[string][]string `json:"errors,omitempty"` } dev-tunnels-0.0.25/go/tunnels/remote_forwarded_ports.go000066400000000000000000000022271450757157500232700ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package tunnels import "sync" type remoteForwardedPorts struct { portsMu sync.RWMutex ports map[uint16]bool notify chan remoteForwardedPortNotification } type remoteForwardedPortNotification struct { port uint16 notificationType remoteForwardedPortNotificationType } type remoteForwardedPortNotificationType int const ( remoteForwardedPortNotificationTypeAdd remoteForwardedPortNotificationType = iota remoteForwardedPortNotificationTypeRemove ) func newRemoteForwardedPorts() *remoteForwardedPorts { return &remoteForwardedPorts{ ports: make(map[uint16]bool), notify: make(chan remoteForwardedPortNotification), } } func (r *remoteForwardedPorts) Add(port uint16) { r.portsMu.Lock() defer r.portsMu.Unlock() r.ports[port] = true notification := remoteForwardedPortNotification{ port: port, notificationType: remoteForwardedPortNotificationTypeAdd, } select { case r.notify <- notification: default: } } func (r *remoteForwardedPorts) hasPort(port uint16) bool { r.portsMu.RLock() defer r.portsMu.RUnlock() return r.ports[port] } dev-tunnels-0.0.25/go/tunnels/request_options.go000066400000000000000000000047111450757157500217540ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package tunnels import ( "fmt" "net/url" ) // Options that are sent in requests to the tunnels service. type TunnelRequestOptions struct { // Token used for authentication for service. AccessToken string // Additional headers to be included in the request. AdditionalHeaders map[string]string // Additional qurey parameters to be included in the request. AdditionalQueryParameters map[string]string // Indicates whether HTTP redirect responses will be automatically followed. FollowRedirects bool // Flag that requests tunnel ports when retrieving a tunnel object. IncludePorts bool // Flag that requests tunnel access control details when listing or searching tunnels. IncludeAccessControl bool // Optional list of tags to filter the requested tunnels or ports. // By default, an item is included if ANY tag matches; set `requireAllTags` to match // ALL tags instead. Tags []string // Flag that indicates whether listed items must match all tags specified in `tags`. // If false, an item is included if any tag matches. RequireAllTags bool // List of token scopes that are requested when retrieving a tunnel or tunnel port object. TokenScopes TunnelAccessScopes // If there is another tunnel with the name requested in updateTunnel, try to acquire the name from the other tunnel. ForceRename bool // Limits the number of tunnels returned when searching or listing tunnels. Limit uint } func (options *TunnelRequestOptions) queryString() string { queryOptions := url.Values{} if options.IncludePorts { queryOptions.Set("includePorts", "true") } if options.IncludeAccessControl { queryOptions.Set("includeAccessControl", "true") } if options.TokenScopes != nil { if err := options.TokenScopes.valid(nil, true); err == nil { for _, scope := range options.TokenScopes { queryOptions.Add("tokenScopes", string(scope)) } } } if options.ForceRename { queryOptions.Set("forceRename", "true") } if options.Tags != nil { for _, tag := range options.Tags { queryOptions.Add("tags", string(tag)) } if options.RequireAllTags { queryOptions.Set("allTags", "true") } } if options.AdditionalQueryParameters != nil { for paramName, paramValue := range options.AdditionalQueryParameters { queryOptions.Add(paramName, paramValue) } } if options.Limit > 0 { queryOptions.Set("limit", fmt.Sprint(options.Limit)) } return queryOptions.Encode() } dev-tunnels-0.0.25/go/tunnels/resource_status.go000066400000000000000000000030201450757157500217330ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/ResourceStatus.cs package tunnels // Current value and limit for a limited resource related to a tunnel or tunnel port. type ResourceStatus struct { // Gets or sets the current value. Current uint64 `json:"current"` // Gets or sets the limit enforced by the service, or null if there is no limit. // // Any requests that would cause the limit to be exceeded may be denied by the service. // For HTTP requests, the response is generally a 403 Forbidden status, with details // about the limit in the response body. Limit uint64 `json:"limit,omitempty"` // Gets or sets an optional source of the `ResourceStatus.Limit`, or null if there is no // limit. LimitSource string `json:"limitSource,omitempty"` RateStatus } // Current value and limit information for a rate-limited operation related to a tunnel or // port. type RateStatus struct { // Gets or sets the length of each period, in seconds, over which the rate is measured. // // For rates that are limited by month (or billing period), this value may represent an // estimate, since the actual duration may vary by the calendar. PeriodSeconds uint32 `json:"periodSeconds,omitempty"` // Gets or sets the unix time in seconds when this status will be reset. ResetTime int64 `json:"resetTime,omitempty"` NamedRateStatus } // A named `RateStatus`. type NamedRateStatus struct { // The name of the rate status. Name string `json:"name"` } dev-tunnels-0.0.25/go/tunnels/service_version_details.go000066400000000000000000000014641450757157500234250ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/ServiceVersionDetails.cs package tunnels // Data contract for service version details. type ServiceVersionDetails struct { // Gets or sets the version of the service. E.g. "1.0.6615.53976". The version // corresponds to the build number. Version string `json:"version"` // Gets or sets the commit ID of the service. CommitID string `json:"commitId"` // Gets or sets the commit date of the service. CommitDate string `json:"commitDate"` // Gets or sets the cluster ID of the service that handled the request. ClusterID string `json:"clusterId"` // Gets or sets the Azure location of the service that handled the request. AzureLocation string `json:"azureLocation"` } dev-tunnels-0.0.25/go/tunnels/socket.go000066400000000000000000000040551450757157500200020ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package tunnels import ( "context" "crypto/tls" "fmt" "io" "net" "net/http" "time" "github.com/gorilla/websocket" ) type socket struct { addr string protocols []string headers http.Header tlsConfig *tls.Config conn *websocket.Conn reader io.Reader } func newSocket(uri string, protocols []string, headers http.Header, tlsConfig *tls.Config) *socket { return &socket{addr: uri, protocols: protocols, headers: headers, tlsConfig: tlsConfig} } func (s *socket) connect(ctx context.Context) error { dialer := websocket.Dialer{ Proxy: http.ProxyFromEnvironment, HandshakeTimeout: 45 * time.Second, TLSClientConfig: s.tlsConfig, Subprotocols: s.protocols, } ws, resp, err := dialer.Dial(s.addr, s.headers) if err != nil { if err == websocket.ErrBadHandshake { return fmt.Errorf("handshake failed with status %d", resp.StatusCode) } return err } s.conn = ws return nil } func (s *socket) Read(b []byte) (int, error) { if s.reader == nil { _, reader, err := s.conn.NextReader() if err != nil { return 0, err } s.reader = reader } bytesRead, err := s.reader.Read(b) if err != nil { s.reader = nil if err == io.EOF { err = nil } } return bytesRead, err } func (s *socket) Write(b []byte) (int, error) { nextWriter, err := s.conn.NextWriter(websocket.BinaryMessage) if err != nil { return 0, err } bytesWritten, err := nextWriter.Write(b) nextWriter.Close() return bytesWritten, err } func (s *socket) Close() error { return s.conn.Close() } func (s *socket) LocalAddr() net.Addr { return s.conn.LocalAddr() } func (s *socket) RemoteAddr() net.Addr { return s.conn.RemoteAddr() } func (s *socket) SetDeadline(t time.Time) error { if err := s.SetReadDeadline(t); err != nil { return err } return s.SetWriteDeadline(t) } func (s *socket) SetReadDeadline(t time.Time) error { return s.conn.SetReadDeadline(t) } func (s *socket) SetWriteDeadline(t time.Time) error { return s.conn.SetWriteDeadline(t) } dev-tunnels-0.0.25/go/tunnels/ssh/000077500000000000000000000000001450757157500167545ustar00rootroot00000000000000dev-tunnels-0.0.25/go/tunnels/ssh/client_session.go000066400000000000000000000155251450757157500223340ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package tunnelssh import ( "bytes" "context" "fmt" "io" "log" "math" "net" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/microsoft/dev-tunnels/go/tunnels/ssh/messages" "golang.org/x/crypto/ssh" ) type portForwardingManager interface { Add(port uint16) } type ClientSSHSession struct { *SSHSession pf portForwardingManager listenersMu sync.Mutex listeners []net.Listener channels uint32 acceptLocalConn bool forwardedPorts map[uint16]uint16 } func NewClientSSHSession(socket net.Conn, pf portForwardingManager, acceptLocalConn bool, logger *log.Logger) *ClientSSHSession { return &ClientSSHSession{ SSHSession: &SSHSession{ socket: socket, logger: logger, }, pf: pf, acceptLocalConn: acceptLocalConn, listeners: make([]net.Listener, 0), forwardedPorts: make(map[uint16]uint16), } } func (s *ClientSSHSession) Connect(ctx context.Context) error { clientConfig := ssh.ClientConfig{ // For now, the client is allowed to skip SSH authentication; // they must have a valid tunnel access token already to get this far. User: "tunnel", Timeout: 10 * time.Second, // TODO: Validate host public keys match those published to the service? // For now, the assumption is only a host with access to the tunnel can get a token // that enables listening for tunnel connections. HostKeyCallback: ssh.InsecureIgnoreHostKey(), } sshClientConn, chans, reqs, err := ssh.NewClientConn(s.socket, "", &clientConfig) if err != nil { return fmt.Errorf("error creating ssh client connection: %w", err) } s.conn = sshClientConn go s.handleGlobalRequests(reqs) sshClient := ssh.NewClient(sshClientConn, chans, nil) s.Session, err = sshClient.NewSession() if err != nil { return fmt.Errorf("error creating ssh client session: %w", err) } s.reader, err = s.Session.StdoutPipe() if err != nil { return fmt.Errorf("error creating ssh session reader: %w", err) } s.writer, err = s.Session.StdinPipe() if err != nil { return fmt.Errorf("error creating ssh session writer: %w", err) } return nil } func (s *ClientSSHSession) handleGlobalRequests(incoming <-chan *ssh.Request) { for r := range incoming { switch r.Type { case messages.PortForwardRequestType: s.handlePortForwardRequest(r) default: // This handles keepalive messages and matches // the behaviour of OpenSSH. r.Reply(false, nil) } } } func (s *ClientSSHSession) handlePortForwardRequest(r *ssh.Request) { req := new(messages.PortForwardRequest) buf := bytes.NewReader(r.Payload) if err := req.Unmarshal(buf); err != nil { s.logger.Printf(fmt.Sprintf("error unmarshalling port forward request: %s", err)) r.Reply(false, nil) return } s.pf.Add(uint16(req.Port())) if s.acceptLocalConn { go s.forwardPort(context.Background(), uint16(req.Port())) } reply := messages.NewPortForwardSuccess(req.Port()) b, err := reply.Marshal() if err != nil { s.logger.Printf(fmt.Sprintf("error marshaling port forward success response: %s", err)) r.Reply(false, nil) return } r.Reply(true, b) } func (s *ClientSSHSession) OpenChannel(ctx context.Context, channelType string, data []byte) (ssh.Channel, error) { channel, reqs, err := s.conn.OpenChannel(channelType, data) if err != nil { return nil, fmt.Errorf("failed to open channel: %w", err) } go ssh.DiscardRequests(reqs) return channel, nil } func (s *ClientSSHSession) forwardPort(ctx context.Context, port uint16) error { var listener net.Listener var i uint16 = 0 for i < 10 { portNum := port + i innerListener, err := net.Listen("tcp", fmt.Sprintf(":%d", portNum)) if err == nil { listener = innerListener break } i++ } if listener == nil { innerListener, err := net.Listen("tcp", ":0") if err != nil { return fmt.Errorf("error creating listener: %w", err) } listener = innerListener } addressSlice := strings.Split(listener.Addr().String(), ":") portNum, err := strconv.ParseUint(addressSlice[len(addressSlice)-1], 10, 16) if err != nil { return fmt.Errorf("error getting port number: %w", err) } if portNum > 0 && portNum <= math.MaxUint16 { s.forwardedPorts[port] = uint16(portNum) } else { return fmt.Errorf("port number %d is out of bounds", portNum) } errc := make(chan error, 1) sendError := func(err error) { // Use non-blocking send, to avoid goroutines getting // stuck in case of concurrent or sequential errors. select { case errc <- err: default: } } fmt.Printf("Client connected at %v to host port %v\n", listener.Addr(), port) go func() { for { conn, err := listener.Accept() if err != nil { sendError(err) return } s.listenersMu.Lock() s.listeners = append(s.listeners, listener) s.listenersMu.Unlock() go func() { if err := s.handleConnection(ctx, conn, port); err != nil { sendError(err) } }() } }() return awaitError(ctx, errc) } func (s *ClientSSHSession) handleConnection(ctx context.Context, conn io.ReadWriteCloser, port uint16) (err error) { defer safeClose(conn, &err) channel, err := s.openStreamingChannel(ctx, port) if err != nil { return fmt.Errorf("failed to open streaming channel: %w", err) } // Ideally we would call safeClose again, but (*ssh.channel).Close // appears to have a bug that causes it return io.EOF spuriously // if its peer closed first; see github.com/golang/go/issues/38115. defer func() { closeErr := channel.Close() if err == nil && closeErr != io.EOF { err = closeErr } }() errs := make(chan error, 2) copyConn := func(w io.Writer, r io.Reader) { _, err := io.Copy(w, r) errs <- err } go copyConn(conn, channel) go copyConn(channel, conn) // Wait until context is cancelled or both copies are done. // Discard errors from io.Copy; they should not cause (e.g.) failures. for i := 0; ; { select { case <-ctx.Done(): return ctx.Err() case <-errs: i++ if i == 2 { return nil } } } } func (s *ClientSSHSession) NextChannelID() uint32 { return atomic.AddUint32(&s.channels, 1) } func (s *ClientSSHSession) openStreamingChannel(ctx context.Context, port uint16) (ssh.Channel, error) { portForwardChannel := messages.NewPortForwardChannel( s.NextChannelID(), "127.0.0.1", uint32(port), "", 0, ) data, err := portForwardChannel.Marshal() if err != nil { return nil, fmt.Errorf("failed to marshal port forward channel open message: %w", err) } channel, err := s.OpenChannel(ctx, portForwardChannel.Type(), data) if err != nil { return nil, fmt.Errorf("failed to open port forward channel: %w", err) } return channel, nil } func (s *ClientSSHSession) Close() error { if s.Session != nil { s.Session.Close() } if s.conn != nil { s.conn.Close() } if s.socket != nil { s.socket.Close() } for _, listener := range s.listeners { listener.Close() } return nil } dev-tunnels-0.0.25/go/tunnels/ssh/local_port_forwarder.go000066400000000000000000000101311450757157500235100ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package tunnelssh import ( "context" "fmt" "io" "net" "os" "syscall" ) type channelOpener interface { openChannel(channelType, originIP string, originPort int, host string, port int) (io.ReadWriteCloser, error) } type localPortForwarder struct { co channelOpener channelType string localIP string localPort int } func newLocalPortForwarder(co channelOpener, channelType string, localIP string, localPort int) *localPortForwarder { return &localPortForwarder{co, channelType, localIP, localPort} } func (l *localPortForwarder) startForwarding(ctx context.Context) (err error) { listenAddress := l.localIP // TODO(josebalius): check for remote version of ssh // and look to implement a wrapper around listener that supports changing the port // probably best to double check that we actually need this? listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", listenAddress, l.localPort)) if err != nil { return fmt.Errorf("failed to listen on local port %d: %v", l.localPort, err) } defer safeClose(listener, &err) // The SSH protocol specifies that "localhost" or "" (any) should be dual-mode (IPv4 and IPv6). // So 2 TCP listener instances are required in those cases. var listener2 net.Listener if l.localIP == "127.0.0.1" || l.localIP == "0.0.0.0" { // Call the factory again to create another listener, but this time with the // corresponding IPv6 local address if listenAddress == "0.0.0.0" { listenAddress = "::" } else { listenAddress = "::1" } listener2, err = net.Listen("tcp", fmt.Sprintf("[%s]:%d", listenAddress, l.localPort)) if err != nil { // If the OS doesn't support IPv6, we are okay with the error, otherwise return if sys, ok := err.(*os.SyscallError); !ok || sys.Err != syscall.EADDRNOTAVAIL { return fmt.Errorf("failed to listen twice on local port %d: %v", l.localPort, err) } } defer safeClose(listener2, &err) } errc := make(chan error, 1) go func() { err := l.acceptConnections(ctx, listener) if err != nil { sendError(errc, err) } }() if listener2 != nil { go func() { err := l.acceptConnections(ctx, listener2) if err != nil { sendError(errc, err) } }() } return awaitError(ctx, errc) } func (l *localPortForwarder) acceptConnections(ctx context.Context, listener net.Listener) error { errc := make(chan error, 1) go func() { for { conn, err := listener.Accept() if err != nil { sendError(errc, err) return } go func() { err := l.handleConnection(ctx, conn) if err != nil { sendError(errc, err) } }() } }() return awaitError(ctx, errc) } func (l *localPortForwarder) handleConnection(ctx context.Context, conn net.Conn) (err error) { defer safeClose(conn, &err) channel, err := l.co.openChannel( l.channelType, conn.RemoteAddr().String(), conn.RemoteAddr().(*net.TCPAddr).Port, l.localIP, l.localPort, ) if err != nil { return fmt.Errorf("failed to open streaming channel: %w", err) } // Ideally we would call safeClose again, but (*ssh.channel).Close // appears to have a bug that causes it return io.EOF spuriously // if its peer closed first; see github.com/golang/go/issues/38115. defer func() { closeErr := channel.Close() if err == nil && closeErr != io.EOF { err = closeErr } }() errs := make(chan error, 2) copyConn := func(w io.Writer, r io.Reader) { _, err := io.Copy(w, r) errs <- err } go copyConn(conn, channel) go copyConn(channel, conn) // Wait until context is cancelled or both copies are done. // Discard errors from io.Copy; they should not cause (e.g.) failures. for i := 0; ; { select { case <-ctx.Done(): return ctx.Err() case <-errs: i++ if i == 2 { return nil } } } } func safeClose(c io.Closer, err *error) { if closerErr := c.Close(); *err == nil { *err = closerErr } } func sendError(errc chan<- error, err error) { select { case errc <- err: default: } } func awaitError(ctx context.Context, errc chan error) error { select { case err := <-errc: return err case <-ctx.Done(): return ctx.Err() } } dev-tunnels-0.0.25/go/tunnels/ssh/local_port_forwarder_test.go000066400000000000000000000045251450757157500245610ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package tunnelssh import ( "bytes" "context" "errors" "fmt" "io" "net" "testing" "time" "github.com/microsoft/dev-tunnels/go/tunnels/ssh/messages" ) type mockChannelOpener struct { openChannelFunc func(string, string, int, string, int) (io.ReadWriteCloser, error) } func (m *mockChannelOpener) openChannel( channelType string, originIP string, originPort int, host string, port int, ) (io.ReadWriteCloser, error) { return m.openChannelFunc(channelType, originIP, originPort, host, port) } type mockChannel struct { *bytes.Buffer } func (m *mockChannel) Close() error { return nil } func TestLocalPortForwarderPortForwardChannelType(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() streamData := "stream-data" host := "127.0.0.1" port := 8080 stream := &mockChannel{bytes.NewBufferString(streamData)} co := &mockChannelOpener{ openChannelFunc: func(channelType, originIP string, originPort int, host string, port int) (io.ReadWriteCloser, error) { if channelType != messages.PortForwardChannelType { return nil, fmt.Errorf("expected channel type %s, got %s", messages.PortForwardChannelType, channelType) } return stream, nil }, } lpf := newLocalPortForwarder(co, messages.PortForwardChannelType, host, port) done := make(chan error, 2) go func() { done <- lpf.startForwarding(ctx) }() go func() { var conn net.Conn // We retry DialTimeout in a loop to deal with a race in forwarder startup. for tries := 0; conn == nil && tries < 2; tries++ { conn, _ = net.DialTimeout("tcp", fmt.Sprintf(":%d", port), 2*time.Second) if conn == nil { time.Sleep(1 * time.Second) } } if conn == nil { done <- errors.New("failed to connect to forwarded port") return } b := make([]byte, len(streamData)) if _, err := conn.Read(b); err != nil && err != io.EOF { done <- fmt.Errorf("reading stream: %w", err) return } if string(b) != streamData { done <- fmt.Errorf("stream data is not expected value, got: %s", string(b)) return } if _, err := conn.Write([]byte("new-data")); err != nil { done <- fmt.Errorf("writing to stream: %w", err) return } done <- nil }() select { case err := <-done: if err != nil { t.Errorf("Unexpected error: %v", err) } } } dev-tunnels-0.0.25/go/tunnels/ssh/messages/000077500000000000000000000000001450757157500205635ustar00rootroot00000000000000dev-tunnels-0.0.25/go/tunnels/ssh/messages/channel_open.go000066400000000000000000000032521450757157500235450ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package messages import ( "bytes" "fmt" "io" ) type channelOpen struct { senderChannel uint32 initialWindowSize uint32 maximumPacketSize uint32 } const ( defaultInitialWindowSize = 1024 * 1024 defaultMaximumPacketSize = 16 * 1024 ) func newChannelOpen(senderChannel uint32, initialWindowSize uint32, maximumPacketSize uint32) *channelOpen { if initialWindowSize == 0 { initialWindowSize = defaultInitialWindowSize } if maximumPacketSize == 0 { maximumPacketSize = defaultMaximumPacketSize } return &channelOpen{ senderChannel: senderChannel, initialWindowSize: initialWindowSize, maximumPacketSize: maximumPacketSize, } } func (c *channelOpen) marshal() ([]byte, error) { buf := new(bytes.Buffer) if err := writeUint32(buf, c.senderChannel); err != nil { return nil, fmt.Errorf("failed to write sender channel: %w", err) } if err := writeUint32(buf, c.initialWindowSize); err != nil { return nil, fmt.Errorf("failed to write initial window size: %w", err) } if err := writeUint32(buf, c.maximumPacketSize); err != nil { return nil, fmt.Errorf("failed to write maximum packet size: %w", err) } return buf.Bytes(), nil } func (c *channelOpen) unmarshal(buf io.Reader) (err error) { c.senderChannel, err = readUint32(buf) if err != nil { return fmt.Errorf("failed to read sender channel: %w", err) } c.initialWindowSize, err = readUint32(buf) if err != nil { return fmt.Errorf("failed to read initial window size: %w", err) } c.maximumPacketSize, err = readUint32(buf) if err != nil { return fmt.Errorf("failed to read maximum packet size: %w", err) } return nil } dev-tunnels-0.0.25/go/tunnels/ssh/messages/channel_open_test.go000066400000000000000000000014521450757157500246040ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package messages import ( "bytes" "testing" ) func TestUnmarshalChannelOpen(t *testing.T) { co := newChannelOpen(11, 0, 0) b, err := co.marshal() if err != nil { t.Error(err) } buf := bytes.NewReader(b) co2 := new(channelOpen) if err := co2.unmarshal(buf); err != nil { t.Error(err) } if co.senderChannel != co2.senderChannel { t.Errorf("senderChannel: want %d, got %d", co.senderChannel, co2.senderChannel) } if co.initialWindowSize != co2.initialWindowSize { t.Errorf("initialWindowSize: want %d, got %d", co.initialWindowSize, co2.initialWindowSize) } if co.maximumPacketSize != co2.maximumPacketSize { t.Errorf("maximumPacketSize: want %d, got %d", co.maximumPacketSize, co2.maximumPacketSize) } } dev-tunnels-0.0.25/go/tunnels/ssh/messages/port_forward_channel_open.go000066400000000000000000000044721450757157500263420ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package messages import ( "bytes" "fmt" "io" ) const PortForwardChannelType = "forwarded-tcpip" type PortForwardChannel struct { channelOpen *channelOpen host string port uint32 originatorIPAddress string originatorPort uint32 } func NewPortForwardChannel(senderChannel uint32, host string, port uint32, originatorIPAddress string, originatorPort uint32) *PortForwardChannel { co := newChannelOpen(senderChannel, 0, 0) return &PortForwardChannel{ channelOpen: co, host: host, port: port, originatorIPAddress: originatorIPAddress, originatorPort: originatorPort, } } func (pfc *PortForwardChannel) Type() string { return PortForwardChannelType } func (pfc *PortForwardChannel) Port() uint32 { return pfc.port } // Marshal returns the byte representation of the PortForwardChannel. // This does not include the channelOpen as it is already included in the ssh message. func (pfc *PortForwardChannel) Marshal() ([]byte, error) { var buff []byte buf := bytes.NewBuffer(buff) if err := writeString(buf, pfc.host); err != nil { return nil, fmt.Errorf("error writing host: %w", err) } if err := writeUint32(buf, pfc.port); err != nil { return nil, fmt.Errorf("error writing port: %w", err) } if err := writeString(buf, pfc.originatorIPAddress); err != nil { return nil, fmt.Errorf("error writing originator ip address: %w", err) } if err := writeUint32(buf, pfc.originatorPort); err != nil { return nil, fmt.Errorf("error writing originator port: %w", err) } return buf.Bytes(), nil } // Unmarshal parses the byte representation of the PortForwardChannel. // This does not include the channelOpen. func (pfc *PortForwardChannel) Unmarshal(buf io.Reader) (err error) { pfc.host, err = readString(buf) if err != nil { return fmt.Errorf("error reading host: %w", err) } pfc.port, err = readUint32(buf) if err != nil { return fmt.Errorf("error reading port: %w", err) } pfc.originatorIPAddress, err = readString(buf) if err != nil { return fmt.Errorf("error reading originator ip address: %w", err) } pfc.originatorPort, err = readUint32(buf) if err != nil { return fmt.Errorf("error reading originator port: %w", err) } return nil } dev-tunnels-0.0.25/go/tunnels/ssh/messages/port_forward_channel_open_test.go000066400000000000000000000016211450757157500273720ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package messages import ( "bytes" "testing" ) func TestUnmarshalPortForwardChannel(t *testing.T) { pfc := NewPortForwardChannel(11, "127.0.0.1", 8001, "999", 8002) b, err := pfc.Marshal() if err != nil { t.Error(err) } buf := bytes.NewReader(b) pfc2 := &PortForwardChannel{} if err := pfc2.Unmarshal(buf); err != nil { t.Error(err) } if pfc2.host != pfc.host { t.Errorf("host: expected %v, got %v", pfc.host, pfc2.host) } if pfc2.port != pfc.port { t.Errorf("port: expected %v, got %v", pfc.port, pfc2.port) } if pfc2.originatorIPAddress != pfc.originatorIPAddress { t.Errorf("originHost: expected %v, got %v", pfc.originatorIPAddress, pfc2.originatorIPAddress) } if pfc2.originatorPort != pfc.originatorPort { t.Errorf("originPort: expected %v, got %v", pfc.originatorPort, pfc2.originatorPort) } } dev-tunnels-0.0.25/go/tunnels/ssh/messages/port_forward_request.go000066400000000000000000000022711450757157500253740ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package messages import ( "bytes" "fmt" "io" ) const ( PortForwardRequestType = "tcpip-forward" ) type PortForwardRequest struct { addressToBind string port uint32 } func NewPortForwardRequest(addressToBind string, port uint32) *PortForwardRequest { return &PortForwardRequest{ addressToBind: addressToBind, port: port, } } func (pfr *PortForwardRequest) Port() uint32 { return pfr.port } func (pfr *PortForwardRequest) Marshal() ([]byte, error) { buf := new(bytes.Buffer) if err := writeString(buf, pfr.addressToBind); err != nil { return nil, fmt.Errorf("error writing address to bind: %w", err) } if err := writeUint32(buf, pfr.port); err != nil { return nil, fmt.Errorf("error writing port: %w", err) } return buf.Bytes(), nil } func (pfr *PortForwardRequest) Unmarshal(buf io.Reader) error { addressToBind, err := readString(buf) if err != nil { return fmt.Errorf("error reading address to bind: %w", err) } port, err := readUint32(buf) if err != nil { return fmt.Errorf("error reading port: %w", err) } pfr.addressToBind = addressToBind pfr.port = port return nil } dev-tunnels-0.0.25/go/tunnels/ssh/messages/port_forward_success.go000066400000000000000000000012641450757157500253550ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package messages import ( "bytes" "io" ) type PortForwardSuccess struct { port uint32 } func NewPortForwardSuccess(port uint32) *PortForwardSuccess { return &PortForwardSuccess{ port: port, } } func (pfs *PortForwardSuccess) Port() uint32 { return pfs.port } func (pfs *PortForwardSuccess) Marshal() ([]byte, error) { buf := new(bytes.Buffer) if err := writeUint32(buf, pfs.port); err != nil { return nil, err } return buf.Bytes(), nil } func (pfs *PortForwardSuccess) Unmarshal(buf io.Reader) error { port, err := readUint32(buf) if err != nil { return err } pfs.port = port return nil } dev-tunnels-0.0.25/go/tunnels/ssh/messages/readers.go000066400000000000000000000010711450757157500225360ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package messages import ( "encoding/binary" "io" ) func readUint32(buf io.Reader) (i uint32, err error) { if err := binary.Read(buf, binary.BigEndian, &i); err != nil { return 0, err } return i, nil } func readString(buf io.Reader) (s string, err error) { var l uint32 if l, err = readUint32(buf); err != nil { return "", err } if l > 0 { b := make([]byte, l) if _, err = io.ReadFull(buf, b); err != nil { return "", err } return string(b), nil } return "", nil } dev-tunnels-0.0.25/go/tunnels/ssh/messages/writers.go000066400000000000000000000013641450757157500226150ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package messages import ( "bytes" "encoding/binary" "fmt" ) func writeString(buf *bytes.Buffer, s string) error { return writeBinary(buf, []byte(s)) } func writeBinary(buf *bytes.Buffer, p []byte) error { if err := writeUint32(buf, uint32(len(p))); err != nil { return fmt.Errorf("failed to write length of binary data: %w", err) } if _, err := buf.Write(p); err != nil { return fmt.Errorf("failed to write binary data: %w", err) } return nil } func writeUint32(buf *bytes.Buffer, v uint32) error { return binary.Write(buf, binary.BigEndian, v) } func writeBool(buf *bytes.Buffer, v bool) error { if v { return buf.WriteByte(1) } return buf.WriteByte(0) } dev-tunnels-0.0.25/go/tunnels/ssh/request.go000066400000000000000000000012521450757157500207730ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package tunnelssh import "golang.org/x/crypto/ssh" // SSHRequest represents an SSH request. type SSHRequest interface { Type() string Reply(ok bool, payload []byte) error } type sshRequest struct { request *ssh.Request } func (sr *sshRequest) Type() string { return sr.request.Type } func (sr *sshRequest) Reply(ok bool, payload []byte) error { return sr.request.Reply(ok, payload) } func (s *Session) convertRequests(reqs <-chan *ssh.Request) <-chan SSHRequest { out := make(chan SSHRequest) go func() { for req := range reqs { out <- &sshRequest{req} } close(out) }() return out } dev-tunnels-0.0.25/go/tunnels/ssh/session.go000066400000000000000000000102671450757157500207740ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package tunnelssh import ( "context" "fmt" "io" "log" "net" "sync" "time" "golang.org/x/crypto/ssh" ) type channelHandlerFunc func(ctx context.Context, channel ssh.NewChannel) type requestHandlerFunc func(ctx context.Context, req SSHRequest) // Session is a wrapper around an SSH session designed for communicating // with a remote tunnels SSH server. It supports the activation of services // via the activator interface. type Session struct { *ssh.Session socket net.Conn conn ssh.Conn channelHandlersMu sync.RWMutex channelHandlers map[string]channelHandlerFunc requestHandlersMu sync.RWMutex requestHandlers map[string]requestHandlerFunc } // NewSession creates a new session. func NewSession(socket net.Conn) *Session { return &Session{socket: socket} } // Connect connects to the remote tunnel SSH server. func (s *Session) Connect(ctx context.Context) (err error) { clientConfig := ssh.ClientConfig{ // For now, the client is allowed to skip SSH authentication; // they must have a valid tunnel access token already to get this far. User: "tunnel", Timeout: 10 * time.Second, // TODO: Validate host public keys match those published to the service? // For now, the assumption is only a host with access to the tunnel can get a token // that enables listening for tunnel connections. HostKeyCallback: ssh.InsecureIgnoreHostKey(), } conn, chans, reqs, err := ssh.NewClientConn(s.socket, "", &clientConfig) if err != nil { return fmt.Errorf("error creating SSH client connection: %w", err) } s.conn = conn go s.handleChannels(ctx, chans) go s.handleRequests(ctx, s.convertRequests(reqs)) sshClient := ssh.NewClient(s.conn, nil, nil) s.Session, err = sshClient.NewSession() if err != nil { return fmt.Errorf("error creating ssh client session: %w", err) } return nil } type activator interface { Activate(ctx context.Context, session *Session) error } // Active calls the Activate method on the activator interface and passes // the session to it. func (s *Session) Activate(ctx context.Context, a activator) error { return a.Activate(ctx, s) } // AddChannelHandler adds a handler for a channel type. func (s *Session) AddChannelHandler(channelType string, handler channelHandlerFunc) { s.channelHandlersMu.Lock() defer s.channelHandlersMu.Unlock() if s.channelHandlers == nil { s.channelHandlers = make(map[string]channelHandlerFunc) } s.channelHandlers[channelType] = handler } // AddRequestHandler adds a handler for a request type. func (s *Session) AddRequestHandler(requestType string, handler requestHandlerFunc) { s.requestHandlersMu.Lock() defer s.requestHandlersMu.Unlock() if s.requestHandlers == nil { s.requestHandlers = make(map[string]requestHandlerFunc) } s.requestHandlers[requestType] = handler } func (s *Session) handleChannels(ctx context.Context, chans <-chan ssh.NewChannel) { for { select { case <-ctx.Done(): return case newChannel := <-chans: s.channelHandlersMu.RLock() handler, ok := s.channelHandlers[newChannel.ChannelType()] s.channelHandlersMu.RUnlock() if !ok { newChannel.Reject(ssh.UnknownChannelType, "unknown channel type") continue } handler(ctx, newChannel) } } } func (s *Session) handleRequests(ctx context.Context, reqs <-chan SSHRequest) { for { select { case <-ctx.Done(): return case req, ok := <-reqs: s.requestHandlersMu.RLock() handler, ok := s.requestHandlers[req.Type()] s.requestHandlersMu.RUnlock() if !ok { // Preserve OpenSSH behavior: if the request type is unknown, // reject it. req.Reply(false, nil) continue } handler(ctx, req) } } } // TODO(josebalius): Deprecate SSHSession struct. type SSHSession struct { *ssh.Session socket net.Conn conn ssh.Conn reader io.Reader writer io.Writer logger *log.Logger } func (s *SSHSession) Read(p []byte) (n int, err error) { return s.reader.Read(p) } func (s *SSHSession) Write(p []byte) (n int, err error) { return s.writer.Write(p) } func (s *SSHSession) SendSessionRequest(name string, wantReply bool, payload []byte) (bool, []byte, error) { return s.conn.SendRequest(name, wantReply, payload) } dev-tunnels-0.0.25/go/tunnels/ssh/session_test.go000066400000000000000000000060771450757157500220370ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package tunnelssh import ( "context" "errors" "testing" "golang.org/x/crypto/ssh" ) type mockActivator struct { ActivateFunc func(context.Context, *Session) error } func (m *mockActivator) Activate(ctx context.Context, s *Session) error { return m.ActivateFunc(ctx, s) } func TestSessionActivate(t *testing.T) { session := NewSession(nil) ma := &mockActivator{ ActivateFunc: func(ctx context.Context, s *Session) error { if s != session { return errors.New("invalid session") } return nil }, } if err := session.Activate(context.Background(), ma); err != nil { t.Errorf("session.Activate() error = %v", err) } } type mockNewChannel struct { AcceptFunc func() (ssh.Channel, <-chan *ssh.Request, error) ChannelTypeFunc func() string RejectFunc func(ssh.RejectionReason, string) error ExtraDataFunc func() []byte } func (m *mockNewChannel) Accept() (ssh.Channel, <-chan *ssh.Request, error) { return m.AcceptFunc() } func (m *mockNewChannel) ExtraData() []byte { return m.ExtraDataFunc() } func (m *mockNewChannel) ChannelType() string { return m.ChannelTypeFunc() } func (m *mockNewChannel) Reject(reason ssh.RejectionReason, message string) error { return m.RejectFunc(reason, message) } func TestSessionChannels(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() channelType := "testChannel" session := NewSession(nil) var n int session.AddChannelHandler(channelType, func(ctx context.Context, newChannel ssh.NewChannel) { n++ }) chans := make(chan ssh.NewChannel) go session.handleChannels(ctx, chans) // successful channel chans <- &mockNewChannel{ ChannelTypeFunc: func() string { return channelType }, } // rejected channel called := make(chan struct{}) chans <- &mockNewChannel{ ChannelTypeFunc: func() string { return "otherChannel" }, RejectFunc: func(reason ssh.RejectionReason, message string) error { close(called) return nil }, } if n != 1 { t.Errorf("n = %d, want 1", n) } // wait for the channel to be rejected <-called } type mockSSHRequest struct { TypeFunc func() string ReplyFunc func(bool, []byte) error } func (m *mockSSHRequest) Type() string { return m.TypeFunc() } func (m *mockSSHRequest) Reply(ok bool, message []byte) error { return m.ReplyFunc(ok, message) } func TestSessionRequests(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() session := NewSession(nil) var n int session.AddRequestHandler("testRequest", func(ctx context.Context, req SSHRequest) { n++ }) reqs := make(chan SSHRequest) go session.handleRequests(ctx, reqs) reqs <- &mockSSHRequest{ TypeFunc: func() string { return "testRequest" }, } if n != 1 { t.Errorf("n = %d, want 1", n) } called := make(chan struct{}) reqs <- &mockSSHRequest{ TypeFunc: func() string { return "otherRequest" }, ReplyFunc: func(ok bool, message []byte) error { close(called) return nil }, } // wait for the request to be rejected <-called } dev-tunnels-0.0.25/go/tunnels/test/000077500000000000000000000000001450757157500171365ustar00rootroot00000000000000dev-tunnels-0.0.25/go/tunnels/test/relay_server.go000066400000000000000000000140531450757157500221720ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package tunnelstest import ( "bytes" "context" "fmt" "io" "net/http" "net/http/httptest" "github.com/gorilla/websocket" "github.com/microsoft/dev-tunnels/go/tunnels/ssh/messages" "golang.org/x/crypto/ssh" ) const sshPrivateKey = `-----BEGIN RSA PRIVATE KEY----- MIICXgIBAAKBgQC6VU6XsMaTot9ogsGcJ+juvJOmDvvCZmgJRTRwKkW0u2BLz4yV rCzQcxaY4kaIuR80Y+1f0BLnZgh4pTREDR0T+p8hUsDSHim1ttKI8rK0hRtJ2qhY lR4qt7P51rPA4KFA9z9gDjTwQLbDq21QMC4+n4d8CL3xRVGtlUAMM3Kl3wIDAQAB AoGBAI8UemkYoSM06gBCh5D1RHQt8eKNltzL7g9QSNfoXeZOC7+q+/TiZPcbqLp0 5lyOalu8b8Ym7J0rSE377Ypj13LyHMXS63e4wMiXv3qOl3GDhMLpypnJ8PwqR2b8 IijL2jrpQfLu6IYqlteA+7e9aEexJa1RRwxYIyq6pG1IYpbhAkEA9nKgtj3Z6ZDC 46IdqYzuUM9ZQdcw4AFr407+lub7tbWe5pYmaq3cT725IwLw081OAmnWJYFDMa/n IPl9YcZSPQJBAMGOMbPs/YPkQAsgNdIUlFtK3o41OrrwJuTRTvv0DsbqDV0LKOiC t8oAQQvjisH6Ew5OOhFyIFXtvZfzQMJppksCQQDWFd+cUICTUEise/Duj9maY3Uz J99ySGnTbZTlu8PfJuXhg3/d3ihrMPG6A1z3cPqaSBxaOj8H07mhQHn1zNU1AkEA hkl+SGPrO793g4CUdq2ahIA8SpO5rIsDoQtq7jlUq0MlhGFCv5Y5pydn+bSjx5MV 933kocf5kUSBntPBIWElYwJAZTm5ghu0JtSE6t3km0iuj7NGAQSdb6mD8+O7C3CP FU3vi+4HlBysaT6IZ/HG+/dBsr4gYp4LGuS7DbaLuYw/uw== -----END RSA PRIVATE KEY-----` type RelayServer struct { httpServer *httptest.Server errc chan error sshConfig *ssh.ServerConfig channels map[string]channelHandler accessToken string serverConn *ssh.ServerConn } type RelayServerOption func(*RelayServer) type channelHandler func(context.Context, ssh.NewChannel) error func NewRelayServer(opts ...RelayServerOption) (*RelayServer, error) { server := &RelayServer{ errc: make(chan error), sshConfig: &ssh.ServerConfig{ NoClientAuth: true, }, } privateKey, err := ssh.ParsePrivateKey([]byte(sshPrivateKey)) if err != nil { return nil, fmt.Errorf("error parsing private key: %w", err) } server.sshConfig.AddHostKey(privateKey) server.httpServer = httptest.NewServer(http.HandlerFunc(makeConnection(server))) for _, opt := range opts { opt(server) } return server, nil } func WithForwardedStream(pfc *messages.PortForwardChannel, port uint16, data *bytes.Buffer) RelayServerOption { return func(server *RelayServer) { if server.channels == nil { server.channels = make(map[string]channelHandler) } server.channels[pfc.Type()] = func(ctx context.Context, ch ssh.NewChannel) error { if pfc.Type() != ch.ChannelType() { return fmt.Errorf("unexpected channel type: %s", ch.ChannelType()) } pfcData, err := pfc.Marshal() if err != nil { return fmt.Errorf("error marshaling port forward channel: %w", err) } channel, reqs, err := ch.Accept() if err != nil { return fmt.Errorf("error accepting channel: %w", err) } go ssh.DiscardRequests(reqs) if len(ch.ExtraData()) != len(pfcData) { return fmt.Errorf("unexpected extra data: %s", ch.ExtraData()) } return forwardStream(ctx, data, channel) } } } func forwardStream(ctx context.Context, stream io.ReadWriter, channel ssh.Channel) (err error) { defer func() { if closeErr := channel.Close(); err == nil && closeErr != io.EOF { err = closeErr } }() errc := make(chan error, 2) copy := func(dst io.Writer, src io.Reader) { _, err := io.Copy(dst, src) errc <- err } go copy(stream, channel) go copy(channel, stream) return awaitError(ctx, errc) } func WithAccessToken(accessToken string) func(*RelayServer) { return func(server *RelayServer) { server.accessToken = accessToken } } func (rs *RelayServer) URL() string { return rs.httpServer.URL } func (rs *RelayServer) Err() <-chan error { return rs.errc } func (rs *RelayServer) sendError(err error) { select { case rs.errc <- err: default: // channel is blocked with a previous error, so we ignore this one } } func (rs *RelayServer) ForwardPort(ctx context.Context, port uint16) error { pfr := messages.NewPortForwardRequest("127.0.0.1", uint32(port)) b, err := pfr.Marshal() if err != nil { return fmt.Errorf("error marshaling port forward request: %w", err) } replied, data, err := rs.serverConn.SendRequest(messages.PortForwardRequestType, true, b) if err != nil { return fmt.Errorf("error sending port forward request: %w", err) } if !replied { return fmt.Errorf("port forward request not replied") } if data == nil { return fmt.Errorf("no data returned") } return nil } var upgrader = websocket.Upgrader{} func makeConnection(server *RelayServer) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() if server.accessToken != "" { if r.Header.Get("Authorization") != server.accessToken { server.sendError(fmt.Errorf("invalid access token")) return } } c, err := upgrader.Upgrade(w, r, nil) if err != nil { server.sendError(fmt.Errorf("error upgrading to websocket: %w", err)) return } defer func() { if err := c.Close(); err != nil { server.sendError(fmt.Errorf("error closing websocket: %w", err)) } }() socketConn := newSocketConn(c) serverConn, chans, reqs, err := ssh.NewServerConn(socketConn, server.sshConfig) if err != nil { server.sendError(fmt.Errorf("error creating ssh server conn: %w", err)) return } go ssh.DiscardRequests(reqs) server.serverConn = serverConn if err := handleChannels(ctx, server, chans); err != nil { server.sendError(fmt.Errorf("error handling channels: %w", err)) return } } } func handleChannels(ctx context.Context, server *RelayServer, chans <-chan ssh.NewChannel) error { errc := make(chan error, 1) go func() { for ch := range chans { if handler, ok := server.channels[ch.ChannelType()]; ok { if err := handler(ctx, ch); err != nil { errc <- err return } } else { // generic accept of the channel to not block _, _, err := ch.Accept() if err != nil { errc <- fmt.Errorf("error accepting channel: %w", err) return } } } }() return awaitError(ctx, errc) } func awaitError(ctx context.Context, errc <-chan error) error { select { case <-ctx.Done(): return ctx.Err() case err := <-errc: return err } } dev-tunnels-0.0.25/go/tunnels/test/socket_conn.go000066400000000000000000000027311450757157500217750ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package tunnelstest import ( "fmt" "io" "sync" "time" "github.com/gorilla/websocket" ) type socketConn struct { *websocket.Conn reader io.Reader writeMutex sync.Mutex readMutex sync.Mutex } func newSocketConn(conn *websocket.Conn) *socketConn { return &socketConn{Conn: conn} } func (s *socketConn) Read(b []byte) (int, error) { s.readMutex.Lock() defer s.readMutex.Unlock() if s.reader == nil { msgType, r, err := s.Conn.NextReader() if err != nil { return 0, fmt.Errorf("error getting next reader: %w", err) } if msgType != websocket.BinaryMessage { return 0, fmt.Errorf("invalid message type") } s.reader = r } bytesRead, err := s.reader.Read(b) if err != nil { s.reader = nil if err == io.EOF { err = nil } } return bytesRead, err } func (s *socketConn) Write(b []byte) (int, error) { s.writeMutex.Lock() defer s.writeMutex.Unlock() w, err := s.Conn.NextWriter(websocket.BinaryMessage) if err != nil { return 0, fmt.Errorf("error getting next writer: %w", err) } n, err := w.Write(b) if err != nil { return 0, fmt.Errorf("error writing: %w", err) } if err := w.Close(); err != nil { return 0, fmt.Errorf("error closing writer: %w", err) } return n, nil } func (s *socketConn) SetDeadline(deadline time.Time) error { if err := s.Conn.SetReadDeadline(deadline); err != nil { return err } return s.Conn.SetWriteDeadline(deadline) } dev-tunnels-0.0.25/go/tunnels/tunnel.go000066400000000000000000000053171450757157500200210ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/Tunnel.cs package tunnels import ( "time" ) // Data contract for tunnel objects managed through the tunnel service REST API. type Tunnel struct { // Gets or sets the ID of the cluster the tunnel was created in. ClusterID string `json:"clusterId,omitempty"` // Gets or sets the generated ID of the tunnel, unique within the cluster. TunnelID string `json:"tunnelId,omitempty"` // Gets or sets the optional short name (alias) of the tunnel. // // The name must be globally unique within the parent domain, and must be a valid // subdomain. Name string `json:"name,omitempty"` // Gets or sets the description of the tunnel. Description string `json:"description,omitempty"` // Gets or sets the tags of the tunnel. Tags []string `json:"tags,omitempty"` // Gets or sets the optional parent domain of the tunnel, if it is not using the default // parent domain. Domain string `json:"domain,omitempty"` // Gets or sets a dictionary mapping from scopes to tunnel access tokens. AccessTokens map[TunnelAccessScope]string `json:"accessTokens,omitempty"` // Gets or sets access control settings for the tunnel. // // See `TunnelAccessControl` documentation for details about the access control model. AccessControl *TunnelAccessControl `json:"accessControl,omitempty"` // Gets or sets default options for the tunnel. Options *TunnelOptions `json:"options,omitempty"` // Gets or sets current connection status of the tunnel. Status *TunnelStatus `json:"status,omitempty"` // Gets or sets an array of endpoints where hosts are currently accepting client // connections to the tunnel. Endpoints []TunnelEndpoint `json:"endpoints,omitempty"` // Gets or sets a list of ports in the tunnel. // // This optional property enables getting info about all ports in a tunnel at the same // time as getting tunnel info, or creating one or more ports at the same time as // creating a tunnel. It is omitted when listing (multiple) tunnels, or when updating // tunnel properties. (For the latter, use APIs to create/update/delete individual ports // instead.) Ports []TunnelPort `json:"ports,omitempty"` // Gets or sets the time in UTC of tunnel creation. Created *time.Time `json:"created,omitempty"` // Gets or the time the tunnel will be deleted if it is not used or updated. Expiration *time.Time `json:"expiration,omitempty"` // Gets or the custom amount of time the tunnel will be valid if it is not used or // updated in seconds. CustomExpiration uint32 `json:"customExpiration,omitempty"` } dev-tunnels-0.0.25/go/tunnels/tunnel_access_control.go000066400000000000000000000023731450757157500231010ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelAccessControl.cs package tunnels // Data contract for access control on a `Tunnel` or `TunnelPort`. // // Tunnels and tunnel ports can each optionally have an access-control property set on // them. An access-control object contains a list (ACL) of entries (ACEs) that specify the // access scopes granted or denied to some subjects. Tunnel ports inherit the ACL from the // tunnel, though ports may include ACEs that augment or override the inherited rules. // Currently there is no capability to define "roles" for tunnel access (where a role // specifies a set of related access scopes), and assign roles to users. That feature may // be added in the future. (It should be represented as a separate `RoleAssignments` // property on this class.) type TunnelAccessControl struct { // Gets or sets the list of access control entries. // // The order of entries is significant: later entries override earlier entries that apply // to the same subject. However, deny rules are always processed after allow rules, // therefore an allow rule cannot override a deny rule for the same subject. Entries []TunnelAccessControlEntry `json:"entries"` } dev-tunnels-0.0.25/go/tunnels/tunnel_access_control_entry.go000066400000000000000000000115041450757157500243160ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelAccessControlEntry.cs package tunnels import ( "time" ) // Data contract for an access control entry on a `Tunnel` or `TunnelPort`. // // An access control entry (ACE) grants or denies one or more access scopes to one or more // subjects. Tunnel ports inherit access control entries from their tunnel, and they may // have additional port-specific entries that augment or override those access rules. type TunnelAccessControlEntry struct { // Gets or sets the access control entry type. Type TunnelAccessControlEntryType `json:"type"` // Gets or sets the provider of the subjects in this access control entry. The provider // impacts how the subject identifiers are resolved and displayed. The provider may be an // identity provider such as AAD, or a system or standard such as "ssh" or "ipv4". // // For user, group, or org ACEs, this value is the name of the identity provider of the // user/group/org IDs. It may be one of the well-known provider names in // `TunnelAccessControlEntry.Providers`, or (in the future) a custom identity provider. // For public key ACEs, this value is the type of public key, e.g. "ssh". For IP address // range ACEs, this value is the IP address version, "ipv4" or "ipv6", or "service-tag" // if the range is defined by an Azure service tag. For anonymous ACEs, this value is // null. Provider string `json:"provider,omitempty"` // Gets or sets a value indicating whether this is an access control entry on a tunnel // port that is inherited from the tunnel's access control list. IsInherited bool `json:"isInherited,omitempty"` // Gets or sets a value indicating whether this entry is a deny rule that blocks access // to the specified users. Otherwise it is an allow rule. // // All deny rules (including inherited rules) are processed after all allow rules. // Therefore a deny ACE cannot be overridden by an allow ACE that is later in the list or // on a more-specific resource. In other words, inherited deny ACEs cannot be overridden. IsDeny bool `json:"isDeny,omitempty"` // Gets or sets a value indicating whether this entry applies to all subjects that are // NOT in the `TunnelAccessControlEntry.Subjects` list. // // Examples: an inverse organizations ACE applies to all users who are not members of the // listed organization(s); an inverse anonymous ACE applies to all authenticated users; // an inverse IP address ranges ACE applies to all clients that are not within any of the // listed IP address ranges. The inverse option is often useful in policies in // combination with `TunnelAccessControlEntry.IsDeny`, for example a policy could deny // access to users who are not members of an organization or are outside of an IP address // range, effectively blocking any tunnels from allowing outside access (because // inherited deny ACEs cannot be overridden). IsInverse bool `json:"isInverse,omitempty"` // Gets or sets an optional organization context for all subjects of this entry. The use // and meaning of this value depends on the `TunnelAccessControlEntry.Type` and // `TunnelAccessControlEntry.Provider` of this entry. // // For AAD users and group ACEs, this value is the AAD tenant ID. It is not currently // used with any other types of ACEs. Organization string `json:"organization,omitempty"` // Gets or sets the subjects for the entry, such as user or group IDs. The format of the // values depends on the `TunnelAccessControlEntry.Type` and // `TunnelAccessControlEntry.Provider` of this entry. Subjects []string `json:"subjects"` // Gets or sets the access scopes that this entry grants or denies to the subjects. // // These must be one or more values from `TunnelAccessScopes`. Scopes []string `json:"scopes"` // Gets or sets the expiration for an access control entry. // // If no value is set then this value is null. Expiration *time.Time `json:"expiration,omitempty"` } // Constants for well-known identity providers. type TunnelAccessControlEntryProviders []TunnelAccessControlEntryProvider type TunnelAccessControlEntryProvider string const ( // Microsoft (AAD) identity provider. TunnelAccessControlEntryProviderMicrosoft TunnelAccessControlEntryProvider = "microsoft" // GitHub identity provider. TunnelAccessControlEntryProviderGitHub TunnelAccessControlEntryProvider = "github" // SSH public keys. TunnelAccessControlEntryProviderSsh TunnelAccessControlEntryProvider = "ssh" // IPv4 addresses. TunnelAccessControlEntryProviderIPv4 TunnelAccessControlEntryProvider = "ipv4" // IPv6 addresses. TunnelAccessControlEntryProviderIPv6 TunnelAccessControlEntryProvider = "ipv6" // Service tags. TunnelAccessControlEntryProviderServiceTag TunnelAccessControlEntryProvider = "service-tag" ) dev-tunnels-0.0.25/go/tunnels/tunnel_access_control_entry_type.go000066400000000000000000000036531450757157500253650ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelAccessControlEntryType.cs package tunnels // Specifies the type of `TunnelAccessControlEntry`. type TunnelAccessControlEntryType string const ( // Uninitialized access control entry type. TunnelAccessControlEntryTypeNone TunnelAccessControlEntryType = "None" // The access control entry refers to all anonymous users. TunnelAccessControlEntryTypeAnonymous TunnelAccessControlEntryType = "Anonymous" // The access control entry is a list of user IDs that are allowed (or denied) access. TunnelAccessControlEntryTypeUsers TunnelAccessControlEntryType = "Users" // The access control entry is a list of groups IDs that are allowed (or denied) access. TunnelAccessControlEntryTypeGroups TunnelAccessControlEntryType = "Groups" // The access control entry is a list of organization IDs that are allowed (or denied) // access. // // All users in the organizations are allowed (or denied) access, unless overridden by // following group or user rules. TunnelAccessControlEntryTypeOrganizations TunnelAccessControlEntryType = "Organizations" // The access control entry is a list of repositories. Users are allowed access to the // tunnel if they have access to the repo. TunnelAccessControlEntryTypeRepositories TunnelAccessControlEntryType = "Repositories" // The access control entry is a list of public keys. Users are allowed access if they // can authenticate using a private key corresponding to one of the public keys. TunnelAccessControlEntryTypePublicKeys TunnelAccessControlEntryType = "PublicKeys" // The access control entry is a list of IP address ranges that are allowed (or denied) // access to the tunnel. Ranges can be IPv4, IPv6, or Azure service tags. TunnelAccessControlEntryTypeIPAddressRanges TunnelAccessControlEntryType = "IPAddressRanges" ) dev-tunnels-0.0.25/go/tunnels/tunnel_access_scopes.go000066400000000000000000000031211450757157500227050ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelAccessScopes.cs package tunnels // Defines scopes for tunnel access tokens. // // A tunnel access token with one or more of these scopes typically also has cluster ID // and tunnel ID claims that limit the access scope to a specific tunnel, and may also // have one or more port claims that further limit the access to particular ports of the // tunnel. type TunnelAccessScopes []TunnelAccessScope type TunnelAccessScope string const ( // Allows creating tunnels. This scope is valid only in policies at the global, domain, // or organization level; it is not relevant to an already-created tunnel or tunnel port. // (Creation of ports requires "manage" or "host" access to the tunnel.) TunnelAccessScopeCreate TunnelAccessScope = "create" // Allows management operations on tunnels and tunnel ports. TunnelAccessScopeManage TunnelAccessScope = "manage" // Allows management operations on all ports of a tunnel, but does not allow updating any // other tunnel properties or deleting the tunnel. TunnelAccessScopeManagePorts TunnelAccessScope = "manage:ports" // Allows accepting connections on tunnels as a host. Includes access to update tunnel // endpoints and ports. TunnelAccessScopeHost TunnelAccessScope = "host" // Allows inspecting tunnel connection activity and data. TunnelAccessScopeInspect TunnelAccessScope = "inspect" // Allows connecting to tunnels or ports as a client. TunnelAccessScopeConnect TunnelAccessScope = "connect" ) dev-tunnels-0.0.25/go/tunnels/tunnel_access_subject.go000066400000000000000000000030601450757157500230520ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelAccessSubject.cs package tunnels // Properties about a subject of a tunnel access control entry (ACE), used when resolving // subject names to IDs when creating new ACEs, or formatting subject IDs to names when // displaying existing ACEs. type TunnelAccessSubject struct { // Gets or sets the type of subject, e.g. user, group, or organization. Type TunnelAccessControlEntryType `json:"type"` // Gets or sets the subject ID. // // The ID is typically a guid or integer that is unique within the scope of the identity // provider or organization, and never changes for that subject. ID string `json:"id,omitempty"` // Gets or sets the subject organization ID, which may be required if an organization is // not implied by the authentication context. OrganizationID string `json:"organizationId,omitempty"` // Gets or sets the partial or full subject name. // // When resolving a subject name to ID, a partial name may be provided, and the full name // is returned if the partial name was successfully resolved. When formatting a subject // ID to name, the full name is returned if the ID was found. Name string `json:"name,omitempty"` // Gets or sets an array of possible subject matches, if a partial name was provided and // did not resolve to a single subject. // // This property applies only when resolving subject names to IDs. Matches []TunnelAccessSubject `json:"matches,omitempty"` } dev-tunnels-0.0.25/go/tunnels/tunnel_authentication_schemes.go000066400000000000000000000016151450757157500246240ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelAuthenticationSchemes.cs package tunnels // Defines string constants for authentication schemes supported by tunnel service APIs. type TunnelAuthenticationSchemes []TunnelAuthenticationScheme type TunnelAuthenticationScheme string const ( // Authentication scheme for AAD (or Microsoft account) access tokens. TunnelAuthenticationSchemeAad TunnelAuthenticationScheme = "aad" // Authentication scheme for GitHub access tokens. TunnelAuthenticationSchemeGitHub TunnelAuthenticationScheme = "github" // Authentication scheme for tunnel access tokens. TunnelAuthenticationSchemeTunnel TunnelAuthenticationScheme = "tunnel" // Authentication scheme for tunnelPlan access tokens. TunnelAuthenticationSchemeTunnelPlan TunnelAuthenticationScheme = "tunnelplan" ) dev-tunnels-0.0.25/go/tunnels/tunnel_connection_mode.go000066400000000000000000000014561450757157500232440ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelConnectionMode.cs package tunnels // Specifies the connection protocol / implementation for a tunnel. // // Depending on the connection mode, hosts or clients might need to use different // authentication and connection protocols. type TunnelConnectionMode string const ( // Connect directly to the host over the local network. // // While it's technically not "tunneling", this mode may be combined with others to // enable choosing the most efficient connection mode available. TunnelConnectionModeLocalNetwork TunnelConnectionMode = "LocalNetwork" // Use the tunnel service's integrated relay function. TunnelConnectionModeTunnelRelay TunnelConnectionMode = "TunnelRelay" ) dev-tunnels-0.0.25/go/tunnels/tunnel_constraints.go000066400000000000000000000171371450757157500224530ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelConstraints.cs package tunnels import ( "regexp" ) const ( // Min length of tunnel cluster ID. TunnelConstraintsClusterIDMinLength = 3 // Max length of tunnel cluster ID. TunnelConstraintsClusterIDMaxLength = 12 // Length of V1 tunnel id. TunnelConstraintsOldTunnelIDLength = 8 // Min length of V2 tunnelId. TunnelConstraintsNewTunnelIDMinLength = 3 // Max length of V2 tunnelId. TunnelConstraintsNewTunnelIDMaxLength = 60 // Length of a tunnel alias. TunnelConstraintsTunnelAliasLength = 8 // Min length of tunnel name. TunnelConstraintsTunnelNameMinLength = 3 // Max length of tunnel name. TunnelConstraintsTunnelNameMaxLength = 60 // Max length of tunnel or port description. TunnelConstraintsDescriptionMaxLength = 400 // Min length of a single tunnel or port tag. TunnelConstraintsTagMinLength = 1 // Max length of a single tunnel or port tag. TunnelConstraintsTagMaxLength = 50 // Maximum number of tags that can be applied to a tunnel or port. TunnelConstraintsMaxTags = 100 // Min length of a tunnel domain. TunnelConstraintsTunnelDomainMinLength = 4 // Max length of a tunnel domain. TunnelConstraintsTunnelDomainMaxLength = 180 // Maximum number of items allowed in the tunnel ports array. The actual limit on number // of ports that can be created may be much lower, and may depend on various resource // limitations or policies. TunnelConstraintsTunnelMaxPorts = 1000 // Maximum number of access control entries (ACEs) in a tunnel or tunnel port access // control list (ACL). TunnelConstraintsAccessControlMaxEntries = 40 // Maximum number of subjects (such as user IDs) in a tunnel or tunnel port access // control entry (ACE). TunnelConstraintsAccessControlMaxSubjects = 100 // Max length of an access control subject or organization ID. TunnelConstraintsAccessControlSubjectMaxLength = 200 // Max length of an access control subject name, when resolving names to IDs. TunnelConstraintsAccessControlSubjectNameMaxLength = 200 // Maximum number of scopes in an access control entry. TunnelConstraintsAccessControlMaxScopes = 10 // Regular expression that can match or validate tunnel cluster ID strings. // // Cluster IDs are alphanumeric; hyphens are not permitted. TunnelConstraintsClusterIDPattern = "^(([a-z]{3,4}[0-9]{1,3})|asse|aue|brs|euw|use)$" // Characters that are valid in tunnel IDs. Includes numbers and lowercase letters, // excluding vowels and 'y' (to avoid accidentally generating any random words). TunnelConstraintsOldTunnelIDChars = "0123456789bcdfghjklmnpqrstvwxz" // Regular expression that can match or validate tunnel ID strings. // // Tunnel IDs are fixed-length and have a limited character set of numbers and lowercase // letters (minus vowels and y). TunnelConstraintsOldTunnelIDPattern = "[" + TunnelConstraintsOldTunnelIDChars + "]{8}" // Characters that are valid in tunnel IDs. Includes numbers and lowercase letters, // excluding vowels and 'y' (to avoid accidentally generating any random words). TunnelConstraintsNewTunnelIDChars = "0123456789abcdefghijklmnopqrstuvwxyz-" // Regular expression that can match or validate tunnel ID strings. // // Tunnel IDs are fixed-length and have a limited character set of numbers and lowercase // letters (minus vowels and y). TunnelConstraintsNewTunnelIDPattern = "[a-z0-9][a-z0-9-]{1,58}[a-z0-9]" // Characters that are valid in tunnel IDs. Includes numbers and lowercase letters, // excluding vowels and 'y' (to avoid accidentally generating any random words). TunnelConstraintsTunnelAliasChars = "0123456789bcdfghjklmnpqrstvwxz" // Regular expression that can match or validate tunnel alias strings. // // Tunnel Aliases are fixed-length and have a limited character set of numbers and // lowercase letters (minus vowels and y). TunnelConstraintsTunnelAliasPattern = "[" + TunnelConstraintsTunnelAliasChars + "]{3,60}" // Regular expression that can match or validate tunnel names. // // Tunnel names are alphanumeric and may contain hyphens. The pattern also allows an // empty string because tunnels may be unnamed. TunnelConstraintsTunnelNamePattern = "([a-z0-9][a-z0-9-]{1,58}[a-z0-9])|(^$)" // Regular expression that can match or validate tunnel or port tags. TunnelConstraintsTagPattern = "[\\w-=]{1,50}" // Regular expression that can match or validate tunnel domains. // // The tunnel service may perform additional contextual validation at the time the domain // is registered. TunnelConstraintsTunnelDomainPattern = "[0-9a-z][0-9a-z-.]{1,158}[0-9a-z]|(^$)" // Regular expression that can match or validate an access control subject or // organization ID. // // The : and / characters are allowed because subjects may include IP addresses and // ranges. The @ character is allowed because MSA subjects may be identified by email // address. TunnelConstraintsAccessControlSubjectPattern = "[0-9a-zA-Z-._:/@]{0,200}" // Regular expression that can match or validate an access control subject name, when // resolving subject names to IDs. // // Note angle-brackets are only allowed when they wrap an email address as part of a // formatted name with email. The service will block any other use of angle-brackets, to // avoid any XSS risks. TunnelConstraintsAccessControlSubjectNamePattern = "[ \\w\\d-.,/'\"_@()<>]{0,200}" ) var ( // Regular expression that can match or validate tunnel cluster ID strings. // // Cluster IDs are alphanumeric; hyphens are not permitted. TunnelConstraintsClusterIDRegex = regexp.MustCompile(TunnelConstraintsClusterIDPattern) // Regular expression that can match or validate tunnel ID strings. // // Tunnel IDs are fixed-length and have a limited character set of numbers and lowercase // letters (minus vowels and y). TunnelConstraintsOldTunnelIDRegex = regexp.MustCompile(TunnelConstraintsOldTunnelIDPattern) // Regular expression that can match or validate tunnel ID strings. // // Tunnel IDs are fixed-length and have a limited character set of numbers and lowercase // letters (minus vowels and y). TunnelConstraintsNewTunnelIDRegex = regexp.MustCompile(TunnelConstraintsNewTunnelIDPattern) // Regular expression that can match or validate tunnel alias strings. // // Tunnel Aliases are fixed-length and have a limited character set of numbers and // lowercase letters (minus vowels and y). TunnelConstraintsTunnelAliasRegex = regexp.MustCompile(TunnelConstraintsTunnelAliasPattern) // Regular expression that can match or validate tunnel names. // // Tunnel names are alphanumeric and may contain hyphens. The pattern also allows an // empty string because tunnels may be unnamed. TunnelConstraintsTunnelNameRegex = regexp.MustCompile(TunnelConstraintsTunnelNamePattern) // Regular expression that can match or validate tunnel or port tags. TunnelConstraintsTagRegex = regexp.MustCompile(TunnelConstraintsTagPattern) // Regular expression that can match or validate tunnel domains. // // The tunnel service may perform additional contextual validation at the time the domain // is registered. TunnelConstraintsTunnelDomainRegex = regexp.MustCompile(TunnelConstraintsTunnelDomainPattern) // Regular expression that can match or validate an access control subject or // organization ID. TunnelConstraintsAccessControlSubjectRegex = regexp.MustCompile(TunnelConstraintsAccessControlSubjectPattern) // Regular expression that can match or validate an access control subject name, when // resolving subject names to IDs. TunnelConstraintsAccessControlSubjectNameRegex = regexp.MustCompile(TunnelConstraintsAccessControlSubjectNamePattern) ) dev-tunnels-0.0.25/go/tunnels/tunnel_endpoint.go000066400000000000000000000102071450757157500217130ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelEndpoint.cs package tunnels // Base class for tunnel connection parameters. // // A tunnel endpoint specifies how and where hosts and clients can connect to a tunnel. // There is a subclass for each connection mode, each having different connection // parameters. A tunnel may have multiple endpoints for one host (or multiple hosts), and // clients can select their preferred endpoint(s) from those depending on network // environment or client capabilities. type TunnelEndpoint struct { // Gets or sets the ID of this endpoint. ID string `json:"id,omitempty"` // Gets or sets the connection mode of the endpoint. // // This property is required when creating or updating an endpoint. The subclass type is // also an indication of the connection mode, but this property is necessary to determine // the subclass type when deserializing. ConnectionMode TunnelConnectionMode `json:"connectionMode"` // Gets or sets the ID of the host that is listening on this endpoint. // // This property is required when creating or updating an endpoint. If the host supports // multiple connection modes, the host's ID is the same for all the endpoints it // supports. However different hosts may simultaneously accept connections at different // endpoints for the same tunnel, if enabled in tunnel options. HostID string `json:"hostId"` // Gets or sets an array of public keys, which can be used by clients to authenticate the // host. HostPublicKeys []string `json:"hostPublicKeys,omitempty"` // Gets or sets a string used to format URIs where a web client can connect to ports of // the tunnel. The string includes a `TunnelEndpoint.PortToken` that must be replaced // with the actual port number. PortURIFormat string `json:"portUriFormat,omitempty"` // Gets or sets the URI where a web client can connect to the default port of the tunnel. TunnelURI string `json:"tunnelUri,omitempty"` // Gets or sets a string used to format ssh command where ssh client can connect to // shared ssh port of the tunnel. The string includes a `TunnelEndpoint.PortToken` that // must be replaced with the actual port number. PortSshCommandFormat string `json:"portSshCommandFormat,omitempty"` // Gets or sets the Ssh command where the Ssh client can connect to the default ssh port // of the tunnel. TunnelSshCommand string `json:"tunnelSshCommand,omitempty"` // Gets or sets the Ssh gateway public key which should be added to the authorized_keys // file so that tunnel service can connect to the shared ssh server. SshGatewayPublicKey string `json:"sshGatewayPublicKey,omitempty"` LocalNetworkTunnelEndpoint TunnelRelayTunnelEndpoint } // Parameters for connecting to a tunnel via a local network connection. // // While a direct connection is technically not "tunneling", tunnel hosts may accept // connections via the local network as an optional more-efficient alternative to a relay. type LocalNetworkTunnelEndpoint struct { // Gets or sets a list of IP endpoints where the host may accept connections. // // A host may accept connections on multiple IP endpoints simultaneously if there are // multiple network interfaces on the host system and/or if the host supports both IPv4 // and IPv6. Each item in the list is a URI consisting of a scheme (which gives an // indication of the network connection protocol), an IP address (IPv4 or IPv6) and a // port number. The URIs do not typically include any paths, because the connection is // not normally HTTP-based. HostEndpoints []string `json:"hostEndpoints"` } // Parameters for connecting to a tunnel via the tunnel service's built-in relay function. type TunnelRelayTunnelEndpoint struct { // Gets or sets the host URI. HostRelayURI string `json:"hostRelayUri,omitempty"` // Gets or sets the client URI. ClientRelayURI string `json:"clientRelayUri,omitempty"` } // Token included in `TunnelEndpoint.PortUriFormat` and // `TunnelEndpoint.PortSshCommandFormat` that is to be replaced by a specified port // number. var PortToken = "{port}" dev-tunnels-0.0.25/go/tunnels/tunnel_header_names.go000066400000000000000000000023731450757157500225130ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelHeaderNames.cs package tunnels // Header names for http requests that Tunnel Service can handle type TunnelHeaderNames []TunnelHeaderName type TunnelHeaderName string const ( // Additional authorization header that can be passed to tunnel web forwarding to // authenticate and authorize the client. The format of the value is the same as // Authorization header that is sent to the Tunnel service by the tunnel SDK. Supported // schemes: "tunnel" with the tunnel access JWT good for 'Connect' scope. TunnelHeaderNameXTunnelAuthorization TunnelHeaderName = "X-Tunnel-Authorization" // Request ID header that nginx ingress controller adds to all requests if it's not // there. TunnelHeaderNameXRequestID TunnelHeaderName = "X-Request-ID" // Github Ssh public key which can be used to validate if it belongs to tunnel's owner. TunnelHeaderNameXGithubSshKey TunnelHeaderName = "X-Github-Ssh-Key" // Header that will skip the antiphishing page when connection to a tunnel through web // forwarding. TunnelHeaderNameXTunnelSkipAntiPhishingPage TunnelHeaderName = "X-Tunnel-Skip-AntiPhishing-Page" ) dev-tunnels-0.0.25/go/tunnels/tunnel_list_by_region.go000066400000000000000000000010561450757157500231050ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelListByRegion.cs package tunnels // Tunnel list by region. type TunnelListByRegion struct { // Azure region name. RegionName string `json:"regionName,omitempty"` // Cluster id in the region. ClusterID string `json:"clusterId,omitempty"` // List of tunnels. Value []TunnelV2 `json:"value,omitempty"` // Error detail if getting list of tunnels in the region failed. Error *ErrorDetail `json:"error,omitempty"` } dev-tunnels-0.0.25/go/tunnels/tunnel_list_by_region_response.go000066400000000000000000000006611450757157500250240ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelListByRegionResponse.cs package tunnels // Data contract for response of a list tunnel by region call. type TunnelListByRegionResponse struct { // List of tunnels Value []TunnelListByRegion `json:"value,omitempty"` // Link to get next page of results. NextLink string `json:"nextLink,omitempty"` } dev-tunnels-0.0.25/go/tunnels/tunnel_list_response.go000066400000000000000000000006141450757157500227650ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelListResponse.cs package tunnels // Data contract for response of a list tunnel call. type TunnelListResponse struct { // List of tunnels Value []TunnelV2 `json:"value,omitempty"` // Link to get next page of results NextLink string `json:"nextLink,omitempty"` } dev-tunnels-0.0.25/go/tunnels/tunnel_options.go000066400000000000000000000052471450757157500215760ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelOptions.cs package tunnels // Data contract for `Tunnel` or `TunnelPort` options. type TunnelOptions struct { // Gets or sets a value indicating whether web-forwarding of this tunnel can run on any // cluster (region) without redirecting to the home cluster. This is only applicable if // the tunnel has a name and web-forwarding uses it. IsGloballyAvailable bool `json:"isGloballyAvailable,omitempty"` // Gets or sets a value for `Host` header rewriting to use in web-forwarding of this // tunnel or port. By default, with this property null or empty, web-forwarding uses // "localhost" to rewrite the header. Web-fowarding will use this property instead if it // is not null or empty. Port-level option, if set, takes precedence over this option on // the tunnel level. The option is ignored if IsHostHeaderUnchanged is true. HostHeader string `json:"hostHeader,omitempty"` // Gets or sets a value indicating whether `Host` header is rewritten or the header value // stays intact. By default, if false, web-forwarding rewrites the host header with the // value from HostHeader property or "localhost". If true, the host header will be // whatever the tunnel's web-forwarding host is, e.g. tunnel-name-8080.devtunnels.ms. // Port-level option, if set, takes precedence over this option on the tunnel level. IsHostHeaderUnchanged bool `json:"isHostHeaderUnchanged,omitempty"` // Gets or sets a value for `Origin` header rewriting to use in web-forwarding of this // tunnel or port. By default, with this property null or empty, web-forwarding uses // "http(s)://localhost" to rewrite the header. Web-fowarding will use this property // instead if it is not null or empty. Port-level option, if set, takes precedence over // this option on the tunnel level. The option is ignored if IsOriginHeaderUnchanged is // true. OriginHeader string `json:"originHeader,omitempty"` // Gets or sets a value indicating whether `Origin` header is rewritten or the header // value stays intact. By default, if false, web-forwarding rewrites the origin header // with the value from OriginHeader property or "http(s)://localhost". If true, the // Origin header will be whatever the tunnel's web-forwarding Origin is, e.g. // https://tunnel-name-8080.devtunnels.ms. Port-level option, if set, takes precedence // over this option on the tunnel level. IsOriginHeaderUnchanged bool `json:"isOriginHeaderUnchanged,omitempty"` // Gets or sets if inspection is enabled for the tunnel. IsInspectionEnabled bool `json:"isInspectionEnabled,omitempty"` } dev-tunnels-0.0.25/go/tunnels/tunnel_port.go000066400000000000000000000060531450757157500210630ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelPort.cs package tunnels // Data contract for tunnel port objects managed through the tunnel service REST API. type TunnelPort struct { // Gets or sets the ID of the cluster the tunnel was created in. ClusterID string `json:"clusterId,omitempty"` // Gets or sets the generated ID of the tunnel, unique within the cluster. TunnelID string `json:"tunnelId,omitempty"` // Gets or sets the IP port number of the tunnel port. PortNumber uint16 `json:"portNumber"` // Gets or sets the optional short name of the port. // // The name must be unique among named ports of the same tunnel. Name string `json:"name,omitempty"` // Gets or sets the optional description of the port. Description string `json:"description,omitempty"` // Gets or sets the tags of the port. Tags []string `json:"tags,omitempty"` // Gets or sets the protocol of the tunnel port. // // Should be one of the string constants from `TunnelProtocol`. Protocol string `json:"protocol,omitempty"` // Gets or sets a value indicating whether this port is a default port for the tunnel. // // A client that connects to a tunnel (by ID or name) without specifying a port number // will connect to the default port for the tunnel, if a default is configured. Or if the // tunnel has only one port then the single port is the implicit default. // // Selection of a default port for a connection also depends on matching the connection // to the port `TunnelPort.Protocol`, so it is possible to configure separate defaults // for distinct protocols like `TunnelProtocol.Http` and `TunnelProtocol.Ssh`. IsDefault bool `json:"isDefault,omitempty"` // Gets or sets a dictionary mapping from scopes to tunnel access tokens. // // Unlike the tokens in `Tunnel.AccessTokens`, these tokens are restricted to the // individual port. AccessTokens map[TunnelAccessScope]string `json:"accessTokens,omitempty"` // Gets or sets access control settings for the tunnel port. // // See `TunnelAccessControl` documentation for details about the access control model. AccessControl *TunnelAccessControl `json:"accessControl,omitempty"` // Gets or sets options for the tunnel port. Options *TunnelOptions `json:"options,omitempty"` // Gets or sets current connection status of the tunnel port. Status *TunnelPortStatus `json:"status,omitempty"` // Gets or sets the username for the ssh service user is trying to forward. // // Should be provided if the `TunnelProtocol` is Ssh. SshUser string `json:"sshUser,omitempty"` // Gets or sets web forwarding URIs. If set, it's a list of absolute URIs where the port // can be accessed with web forwarding. PortForwardingURIs []string `json:"portForwardingUris"` // Gets or sets inspection URI. If set, it's an absolute URIs where the port's traffic // can be inspected. InspectionURI string `json:"inspectionUri"` } dev-tunnels-0.0.25/go/tunnels/tunnel_port_list_response.go000066400000000000000000000006361450757157500240350ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelPortListResponse.cs package tunnels // Data contract for response of a list tunnel ports call. type TunnelPortListResponse struct { // List of tunnels Value []TunnelPortV2 `json:"value,omitempty"` // Link to get next page of results NextLink string `json:"nextLink,omitempty"` } dev-tunnels-0.0.25/go/tunnels/tunnel_port_status.go000066400000000000000000000034041450757157500224630ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelPortStatus.cs package tunnels import ( "time" ) // Data contract for `TunnelPort` status. type TunnelPortStatus struct { // Gets or sets the current value and limit for the number of clients connected to the // port. // // This client connection count does not include non-port-specific connections such as // SDK and SSH clients. See `TunnelStatus.ClientConnectionCount` for status of those // connections. This count also does not include HTTP client connections, unless they // are upgraded to websockets. HTTP connections are counted per-request rather than // per-connection: see `TunnelPortStatus.HttpRequestRate`. ClientConnectionCount *ResourceStatus `json:"clientConnectionCount,omitempty"` // Gets or sets the UTC date time when a client was last connected to the port, or null // if a client has never connected. LastClientConnectionTime *time.Time `json:"lastClientConnectionTime,omitempty"` // Gets or sets the current value and limit for the rate of client connections to the // tunnel port. // // This client connection rate does not count non-port-specific connections such as SDK // and SSH clients. See `TunnelStatus.ClientConnectionRate` for those connection types. // This also does not include HTTP connections, unless they are upgraded to websockets. // HTTP connections are counted per-request rather than per-connection: see // `TunnelPortStatus.HttpRequestRate`. ClientConnectionRate *RateStatus `json:"clientConnectionRate,omitempty"` // Gets or sets the current value and limit for the rate of HTTP requests to the tunnel // port. HttpRequestRate *RateStatus `json:"httpRequestRate,omitempty"` } dev-tunnels-0.0.25/go/tunnels/tunnel_port_v2.go000066400000000000000000000060631450757157500214730ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelPortV2.cs package tunnels // Data contract for tunnel port objects managed through the tunnel service REST API. type TunnelPortV2 struct { // Gets or sets the ID of the cluster the tunnel was created in. ClusterID string `json:"clusterId,omitempty"` // Gets or sets the generated ID of the tunnel, unique within the cluster. TunnelID string `json:"tunnelId,omitempty"` // Gets or sets the IP port number of the tunnel port. PortNumber uint16 `json:"portNumber"` // Gets or sets the optional short name of the port. // // The name must be unique among named ports of the same tunnel. Name string `json:"name,omitempty"` // Gets or sets the optional description of the port. Description string `json:"description,omitempty"` // Gets or sets the tags of the port. Labels []string `json:"labels,omitempty"` // Gets or sets the protocol of the tunnel port. // // Should be one of the string constants from `TunnelProtocol`. Protocol string `json:"protocol,omitempty"` // Gets or sets a value indicating whether this port is a default port for the tunnel. // // A client that connects to a tunnel (by ID or name) without specifying a port number // will connect to the default port for the tunnel, if a default is configured. Or if the // tunnel has only one port then the single port is the implicit default. // // Selection of a default port for a connection also depends on matching the connection // to the port `TunnelPortV2.Protocol`, so it is possible to configure separate defaults // for distinct protocols like `TunnelProtocol.Http` and `TunnelProtocol.Ssh`. IsDefault bool `json:"isDefault,omitempty"` // Gets or sets a dictionary mapping from scopes to tunnel access tokens. // // Unlike the tokens in `Tunnel.AccessTokens`, these tokens are restricted to the // individual port. AccessTokens map[TunnelAccessScope]string `json:"accessTokens,omitempty"` // Gets or sets access control settings for the tunnel port. // // See `TunnelAccessControl` documentation for details about the access control model. AccessControl *TunnelAccessControl `json:"accessControl,omitempty"` // Gets or sets options for the tunnel port. Options *TunnelOptions `json:"options,omitempty"` // Gets or sets current connection status of the tunnel port. Status *TunnelPortStatus `json:"status,omitempty"` // Gets or sets the username for the ssh service user is trying to forward. // // Should be provided if the `TunnelProtocol` is Ssh. SshUser string `json:"sshUser,omitempty"` // Gets or sets web forwarding URIs. If set, it's a list of absolute URIs where the port // can be accessed with web forwarding. PortForwardingURIs []string `json:"portForwardingUris"` // Gets or sets inspection URI. If set, it's an absolute URIs where the port's traffic // can be inspected. InspectionURI string `json:"inspectionUri"` } dev-tunnels-0.0.25/go/tunnels/tunnel_protocol.go000066400000000000000000000014301450757157500217320ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelProtocol.cs package tunnels // Defines possible values for the protocol of a `TunnelPort`. type TunnelProtocol string const ( // The protocol is automatically detected. (TODO: Define detection semantics.) TunnelProtocolAuto TunnelProtocol = "auto" // Unknown TCP protocol. TunnelProtocolTcp TunnelProtocol = "tcp" // Unknown UDP protocol. TunnelProtocolUdp TunnelProtocol = "udp" // SSH protocol. TunnelProtocolSsh TunnelProtocol = "ssh" // Remote desktop protocol. TunnelProtocolRdp TunnelProtocol = "rdp" // HTTP protocol. TunnelProtocolHttp TunnelProtocol = "http" // HTTPS protocol. TunnelProtocolHttps TunnelProtocol = "https" ) dev-tunnels-0.0.25/go/tunnels/tunnel_service_properties.go000066400000000000000000000060351450757157500240130ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelServiceProperties.cs package tunnels // Provides environment-dependent properties about the service. type TunnelServiceProperties struct { // Gets the base URI of the service. ServiceURI string `json:"serviceUri"` // Gets the public AAD AppId for the service. // // Clients specify this AppId as the audience property when authenticating to the // service. ServiceAppID string `json:"serviceAppId"` // Gets the internal AAD AppId for the service. // // Other internal services specify this AppId as the audience property when // authenticating to the tunnel service. Production services must be in the AME tenant to // use this appid. ServiceInternalAppID string `json:"serviceInternalAppId"` // Gets the client ID for the service's GitHub app. // // Clients apps that authenticate tunnel users with GitHub specify this as the client ID // when requesting a user token. GitHubAppClientID string `json:"gitHubAppClientId"` } // Global DNS name of the production tunnel service. var prodDnsName = "global.rel.tunnels.api.visualstudio.com" // Global DNS name of the pre-production tunnel service. var ppeDnsName = "global.rel.tunnels.ppe.api.visualstudio.com" // Global DNS name of the development tunnel service. var devDnsName = "global.ci.tunnels.dev.api.visualstudio.com" // First-party app ID: `Visual Studio Tunnel Service` // // Used for authenticating AAD/MSA users, and service principals outside the AME tenant, // in the PROD service environment. var prodFirstPartyAppID = "46da2f7e-b5ef-422a-88d4-2a7f9de6a0b2" // First-party app ID: `Visual Studio Tunnel Service - Test` // // Used for authenticating AAD/MSA users, and service principals outside the AME tenant, // in the PPE and DEV service environments. var nonProdFirstPartyAppID = "54c45752-bacd-424a-b928-652f3eca2b18" // Third-party app ID: `tunnels-prod-app-sp` // // Used for authenticating internal AAD service principals in the AME tenant, in the PROD // service environment. var prodThirdPartyAppID = "ce65d243-a913-4cae-a7dd-cb52e9f77647" // Third-party app ID: `tunnels-ppe-app-sp` // // Used for authenticating internal AAD service principals in the AME tenant, in the PPE // service environment. var ppeThirdPartyAppID = "544167a6-f431-4518-aac6-2fd50071928e" // Third-party app ID: `tunnels-dev-app-sp` // // Used for authenticating internal AAD service principals in the corp tenant (not AME!), // in the DEV service environment. var devThirdPartyAppID = "a118c979-0249-44bb-8f95-eb0457127aeb" // GitHub App Client ID for 'Visual Studio Tunnel Service' // // Used by client apps that authenticate tunnel users with GitHub, in the PROD service // environment. var prodGitHubAppClientID = "Iv1.e7b89e013f801f03" // GitHub App Client ID for 'Visual Studio Tunnel Service - Test' // // Used by client apps that authenticate tunnel users with GitHub, in the PPE and DEV // service environments. var nonProdGitHubAppClientID = "Iv1.b231c327f1eaa229" dev-tunnels-0.0.25/go/tunnels/tunnel_status.go000066400000000000000000000106561450757157500214260ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelStatus.cs package tunnels import ( "time" ) // Data contract for `Tunnel` status. type TunnelStatus struct { // Gets or sets the current value and limit for the number of ports on the tunnel. PortCount *ResourceStatus `json:"portCount,omitempty"` // Gets or sets the current value and limit for the number of hosts currently accepting // connections to the tunnel. // // This is typically 0 or 1, but may be more than 1 if the tunnel options allow multiple // hosts. HostConnectionCount *ResourceStatus `json:"hostConnectionCount,omitempty"` // Gets or sets the UTC time when a host was last accepting connections to the tunnel, or // null if a host has never connected. LastHostConnectionTime *time.Time `json:"lastHostConnectionTime,omitempty"` // Gets or sets the current value and limit for the number of clients connected to the // tunnel. // // This counts non-port-specific client connections, which is SDK and SSH clients. See // `TunnelPortStatus` for status of per-port client connections. ClientConnectionCount *ResourceStatus `json:"clientConnectionCount,omitempty"` // Gets or sets the UTC time when a client last connected to the tunnel, or null if a // client has never connected. // // This reports times for non-port-specific client connections, which is SDK client and // SSH clients. See `TunnelPortStatus` for per-port client connections. LastClientConnectionTime *time.Time `json:"lastClientConnectionTime,omitempty"` // Gets or sets the current value and limit for the rate of client connections to the // tunnel. // // This counts non-port-specific client connections, which is SDK client and SSH clients. // See `TunnelPortStatus` for status of per-port client connections. ClientConnectionRate *RateStatus `json:"clientConnectionRate,omitempty"` // Gets or sets the current value and limit for the rate of bytes being received by the // tunnel host and uploaded by tunnel clients. // // All types of tunnel and port connections, from potentially multiple clients, can // contribute to this rate. The reported rate may differ slightly from the rate // measurable by applications, due to protocol overhead. Data rate status reporting is // delayed by a few seconds, so this value is a snapshot of the data transfer rate from a // few seconds earlier. UploadRate *RateStatus `json:"uploadRate,omitempty"` // Gets or sets the current value and limit for the rate of bytes being sent by the // tunnel host and downloaded by tunnel clients. // // All types of tunnel and port connections, from potentially multiple clients, can // contribute to this rate. The reported rate may differ slightly from the rate // measurable by applications, due to protocol overhead. Data rate status reporting is // delayed by a few seconds, so this value is a snapshot of the data transfer rate from a // few seconds earlier. DownloadRate *RateStatus `json:"downloadRate,omitempty"` // Gets or sets the total number of bytes received by the tunnel host and uploaded by // tunnel clients, over the lifetime of the tunnel. // // All types of tunnel and port connections, from potentially multiple clients, can // contribute to this total. The reported value may differ slightly from the value // measurable by applications, due to protocol overhead. Data transfer status reporting // is delayed by a few seconds. UploadTotal uint64 `json:"uploadTotal,omitempty"` // Gets or sets the total number of bytes sent by the tunnel host and downloaded by // tunnel clients, over the lifetime of the tunnel. // // All types of tunnel and port connections, from potentially multiple clients, can // contribute to this total. The reported value may differ slightly from the value // measurable by applications, due to protocol overhead. Data transfer status reporting // is delayed by a few seconds. DownloadTotal uint64 `json:"downloadTotal,omitempty"` // Gets or sets the current value and limit for the rate of management API read // operations for the tunnel or tunnel ports. ApiReadRate *RateStatus `json:"apiReadRate,omitempty"` // Gets or sets the current value and limit for the rate of management API update // operations for the tunnel or tunnel ports. ApiUpdateRate *RateStatus `json:"apiUpdateRate,omitempty"` } dev-tunnels-0.0.25/go/tunnels/tunnel_v2.go000066400000000000000000000053271450757157500204310ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelV2.cs package tunnels import ( "time" ) // Data contract for tunnel objects managed through the tunnel service REST API. type TunnelV2 struct { // Gets or sets the ID of the cluster the tunnel was created in. ClusterID string `json:"clusterId,omitempty"` // Gets or sets the generated ID of the tunnel, unique within the cluster. TunnelID string `json:"tunnelId,omitempty"` // Gets or sets the optional short name (alias) of the tunnel. // // The name must be globally unique within the parent domain, and must be a valid // subdomain. Name string `json:"name,omitempty"` // Gets or sets the description of the tunnel. Description string `json:"description,omitempty"` // Gets or sets the tags of the tunnel. Labels []string `json:"labels,omitempty"` // Gets or sets the optional parent domain of the tunnel, if it is not using the default // parent domain. Domain string `json:"domain,omitempty"` // Gets or sets a dictionary mapping from scopes to tunnel access tokens. AccessTokens map[TunnelAccessScope]string `json:"accessTokens,omitempty"` // Gets or sets access control settings for the tunnel. // // See `TunnelAccessControl` documentation for details about the access control model. AccessControl *TunnelAccessControl `json:"accessControl,omitempty"` // Gets or sets default options for the tunnel. Options *TunnelOptions `json:"options,omitempty"` // Gets or sets current connection status of the tunnel. Status *TunnelStatus `json:"status,omitempty"` // Gets or sets an array of endpoints where hosts are currently accepting client // connections to the tunnel. Endpoints []TunnelEndpoint `json:"endpoints,omitempty"` // Gets or sets a list of ports in the tunnel. // // This optional property enables getting info about all ports in a tunnel at the same // time as getting tunnel info, or creating one or more ports at the same time as // creating a tunnel. It is omitted when listing (multiple) tunnels, or when updating // tunnel properties. (For the latter, use APIs to create/update/delete individual ports // instead.) Ports []TunnelPortV2 `json:"ports,omitempty"` // Gets or sets the time in UTC of tunnel creation. Created *time.Time `json:"created,omitempty"` // Gets or the time the tunnel will be deleted if it is not used or updated. Expiration *time.Time `json:"expiration,omitempty"` // Gets or the custom amount of time the tunnel will be valid if it is not used or // updated in seconds. CustomExpiration uint32 `json:"customExpiration,omitempty"` } dev-tunnels-0.0.25/go/tunnels/tunnels.go000066400000000000000000000111711450757157500201770ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package tunnels import ( "encoding/json" "fmt" "github.com/rodaine/table" ) const PackageVersion = "0.0.25" func (tunnel *Tunnel) requestObject() (*Tunnel, error) { convertedTunnel := &Tunnel{ Name: tunnel.Name, Domain: tunnel.Domain, Description: tunnel.Description, Tags: tunnel.Tags, Options: tunnel.Options, Endpoints: tunnel.Endpoints, CustomExpiration: tunnel.CustomExpiration, } if tunnel.AccessControl != nil { var newEntries []TunnelAccessControlEntry for _, entry := range tunnel.AccessControl.Entries { if !entry.IsInherited { newEntries = append(newEntries, entry) } } convertedTunnel.AccessControl = &TunnelAccessControl{ Entries: newEntries, } } var convertedPorts []TunnelPort for _, port := range tunnel.Ports { convertedPort, err := port.requestObject(tunnel) if err != nil { return nil, err } convertedPorts = append(convertedPorts, *convertedPort) } convertedTunnel.Ports = convertedPorts return convertedTunnel, nil } func (t *Tunnel) Table() table.Table { tbl := table.New("Tunnel Properties", " ") var accessTokens string for scope := range t.AccessTokens { if len(accessTokens) == 0 { accessTokens += string(scope) } else { accessTokens += fmt.Sprintf(", %s", scope) } } var ports string for _, port := range t.Ports { if len(ports) == 0 { ports += fmt.Sprintf("%d - %s", port.PortNumber, port.Protocol) } else { ports += fmt.Sprintf(", %d - %s", port.PortNumber, port.Protocol) } } tbl.AddRow("ClusterId", t.ClusterID) tbl.AddRow("TunnelId", t.TunnelID) tbl.AddRow("Name", t.Name) tbl.AddRow("Description", t.Description) tbl.AddRow("Tags", fmt.Sprintf("%v", t.Tags)) if t.AccessControl != nil { tbl.AddRow("Access Control", fmt.Sprintf("%v", *t.AccessControl)) } tbl.AddRow("Ports", ports) tbl.AddRow("Host Connections", t.Status.HostConnectionCount) tbl.AddRow("Client Connections", t.Status.ClientConnectionCount) tbl.AddRow("Available Scopes", accessTokens) return tbl } func (tp *TunnelPort) Table() table.Table { tbl := table.New("TunnelPort Properties", " ") var accessTokens string for scope := range tp.AccessTokens { if len(accessTokens) == 0 { accessTokens += string(scope) } else { accessTokens += fmt.Sprintf(", %s", scope) } } tbl.AddRow("ClusterId", tp.ClusterID) tbl.AddRow("TunnelId", tp.TunnelID) tbl.AddRow("PortNumber", tp.PortNumber) tbl.AddRow("Protocol", tp.Protocol) if tp.AccessControl != nil { tbl.AddRow("Access Control", fmt.Sprintf("%v", *tp.AccessControl)) } tbl.AddRow("Client Connections", tp.Status.ClientConnectionCount) tbl.AddRow("Last Connection Time", tp.Status.LastClientConnectionTime) return tbl } func NewTunnelPort(portNumber uint16, clusterId string, tunnelId string, protocol TunnelProtocol) *TunnelPort { protocolValue := string(protocol) if len(protocolValue) == 0 { protocolValue = string(TunnelProtocolAuto) } port := &TunnelPort{ PortNumber: portNumber, ClusterID: clusterId, TunnelID: tunnelId, Protocol: protocolValue, } return port } func (tunnelPort *TunnelPort) requestObject(tunnel *Tunnel) (*TunnelPort, error) { if tunnelPort.ClusterID != "" && tunnel.ClusterID != "" && tunnelPort.ClusterID != tunnel.ClusterID { return nil, fmt.Errorf("tunnel port cluster ID '%s' does not match tunnel cluster ID '%s'", tunnelPort.ClusterID, tunnel.ClusterID) } if tunnelPort.TunnelID != "" && tunnel.TunnelID != "" && tunnelPort.TunnelID != tunnel.TunnelID { return nil, fmt.Errorf("tunnel port tunnel ID does not match tunnel") } convertedPort := &TunnelPort{ PortNumber: tunnelPort.PortNumber, Protocol: tunnelPort.Protocol, IsDefault: tunnelPort.IsDefault, Description: tunnelPort.Description, Tags: tunnelPort.Tags, SshUser: tunnelPort.SshUser, Options: tunnelPort.Options, } if tunnelPort.AccessControl != nil { var newEntries []TunnelAccessControlEntry for _, entry := range tunnelPort.AccessControl.Entries { if !entry.IsInherited { newEntries = append(newEntries, entry) } } convertedPort.AccessControl = &TunnelAccessControl{ Entries: newEntries, } } return convertedPort, nil } func (rs *ResourceStatus) UnmarshalJSON(data []byte) (err error) { // First attempt to un-marshal as a ResourceStatus object. var obj map[string]uint64 err = json.Unmarshal(data, &obj) if err == nil { rs.Current = obj["current"] rs.Limit = obj["limit"] } else { // It's not an object - unmarshal as a simple number. err = json.Unmarshal(data, &rs.Current) rs.Limit = 0 } return err } dev-tunnels-0.0.25/java/000077500000000000000000000000001450757157500150035ustar00rootroot00000000000000dev-tunnels-0.0.25/java/.editorconfig000066400000000000000000000005711450757157500174630ustar00rootroot00000000000000# EditorConfig is awesome: https://EditorConfig.org # top-most EditorConfig file root = true [*.java] indent_style = space indent_size = 2 end_of_line = crlf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.xml] indent_style = space indent_size = 2 end_of_line = crlf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true dev-tunnels-0.0.25/java/.gitignore000066400000000000000000000000601450757157500167670ustar00rootroot00000000000000# Java *.class *.jar target/ .flattened-pom.xml dev-tunnels-0.0.25/java/.vscode/000077500000000000000000000000001450757157500163445ustar00rootroot00000000000000dev-tunnels-0.0.25/java/.vscode/extensions.json000066400000000000000000000001061450757157500214330ustar00rootroot00000000000000{ "recommendations": [ "vscjava.vscode-java-pack" ] } dev-tunnels-0.0.25/java/.vscode/launch.json000066400000000000000000000012201450757157500205040ustar00rootroot00000000000000{ // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "type": "java", "name": "Launch ConnectionTest", "request": "launch", "mainClass": "com.microsoft.tunnels.ConnectionTest", "projectName": "tunnels-java-sdk" }, { "type": "java", "name": "Launch Current File", "request": "launch", "mainClass": "${file}" } ] } dev-tunnels-0.0.25/java/.vscode/settings.json000066400000000000000000000002671450757157500211040ustar00rootroot00000000000000{ "java.configuration.updateBuildConfiguration": "automatic", "java.checkstyle.configuration": "/google_checks.xml", "java.format.settings.url": "eclipse-formatter.xml" } dev-tunnels-0.0.25/java/README.md000066400000000000000000000037531450757157500162720ustar00rootroot00000000000000## Tunnels Java SDK ### Setting up development These instructions assume you are using vscode for development as SDK is configured for it. 1. Clone this repo and open the `java` folder in vscode. 2. Install the recommended extension pack "Extension Pack for Java" 3. The extension will prompt you to install a JDK. Choose JDK version 11 (LTS). 4. Download and [install Maven](https://maven.apache.org/install.html). - 👉 You may need to set up [M2_HOME and/or additional environment variables](https://www.tutorialspoint.com/maven/maven_environment_setup.htm) manually. 5. Once you have the extension and JDK installed, run `mvn test` (see next section for test setup). ### Testing 1. Get a user token using the CLI command: `user show --verbose` 2. Create a new environment variable `TEST_TUNNEL_TOKEN` with a string value "Bearer ". 3. Create a new environment variable `TEST_TUNNEL_NAME` with a value containing the name of the tunnel. 4. Optionally: set `TEST_TUNNEL_VERBOSE=1` to enable verbose console logging during tests. 5. Use the CLI to host the tunnel. 6. Run the tests with `mvn test`, or run a single test with `mvn test -Dtest=TunnelClientTests#connectClient` ### Publishing The Tunnels Java SDK is published as a GitHub package through a [GitHub Action](../.github/workflows/java-sdk-release.yml). Since the repo is shared by multiple language SDKs, the Java packages are distinguished with a tag of the form `java-vX.Y.Z`. See [tags](https://github.com/microsoft/dev-tunnels/tags) for examples. Follow these steps to publish a new version of the Java package: 1. Create a new [release](https://github.com/microsoft/dev-tunnels/releases/new). 2. Create a new tag in the `java-vX.Y.Z` format. The version needs to be greater than the latest `java-*` version in the [releases](https://github.com/microsoft/dev-tunnels/releases) page. 3. Set the release title the same as the version tag. Increment the major, minor, or patch version from the latest release as appropriate. 4. Publish the release. dev-tunnels-0.0.25/java/pom.xml000066400000000000000000000143611450757157500163250ustar00rootroot00000000000000 4.0.0 com.microsoft.tunnels tunnels-java-sdk ${revision} tunnels-java-sdk https://github.com/microsoft/dev-tunnels 11 UTF-8 11 11 0.1.0 org.apache.sshd sshd-netty 2.8.0 io.netty netty-codec-http 4.1.86.Final io.netty netty-handler 4.1.94.Final io.netty netty-transport 4.1.72.Final org.apache.maven.shared maven-shared-utils 3.3.4 commons-io commons-io 2.7 com.google.code.gson gson 2.9.0 junit junit 4.13.1 test org.slf4j slf4j-jdk14 1.7.26 test org.apache.maven.plugins maven-checkstyle-plugin 3.1.2 google_checks.xml UTF-8 true true false **/contracts/* checkstyle-check compile check com.puppycrawl.tools checkstyle 9.3 org.codehaus.mojo flatten-maven-plugin 1.1.0 true resolveCiFriendliesOnly flatten process-resources flatten flatten.clean clean clean maven-clean-plugin 3.1.0 maven-resources-plugin 3.0.2 maven-compiler-plugin 3.8.0 maven-surefire-plugin 2.22.1 maven-jar-plugin 3.0.2 true maven-install-plugin 2.5.2 maven-deploy-plugin 2.8.2 maven-site-plugin 3.7.1 maven-project-info-reports-plugin 3.0.0 org.slf4j slf4j-api 1.7.26 github GitHub Packages https://maven.pkg.github.com/microsoft/dev-tunnels dev-tunnels-0.0.25/java/src/000077500000000000000000000000001450757157500155725ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/main/000077500000000000000000000000001450757157500165165ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/main/java/000077500000000000000000000000001450757157500174375ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/main/java/com/000077500000000000000000000000001450757157500202155ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/main/java/com/microsoft/000077500000000000000000000000001450757157500222225ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/000077500000000000000000000000001450757157500237125ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/connections/000077500000000000000000000000001450757157500262345ustar00rootroot00000000000000CancelTcpipForwardRequestHandler.java000066400000000000000000000060701450757157500354040ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/connections// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.connections; import java.util.Objects; import java.util.function.IntUnaryOperator; import org.apache.sshd.common.SshConstants; import org.apache.sshd.common.forward.Forwarder; import org.apache.sshd.common.session.ConnectionService; import org.apache.sshd.common.session.Session; import org.apache.sshd.common.session.helpers.AbstractConnectionServiceRequestHandler; import org.apache.sshd.common.util.buffer.Buffer; import org.apache.sshd.common.util.functors.Int2IntFunction; import org.apache.sshd.common.util.net.SshdSocketAddress; /** * Handler for "cancel-tcpip-forward" global request. * *

* Modified from org.apache.sshd.server.global.CancelTcpipForwardHandler to * track the requested * port since the default implementation only keeps track of the local port. *

*/ class CancelTcpipForwardRequestHandler extends AbstractConnectionServiceRequestHandler { public static final String REQUEST = "cancel-tcpip-forward"; /** * Default growth factor function used to resize response buffers. */ public static final IntUnaryOperator RESPONSE_BUFFER_GROWTH_FACTOR = Int2IntFunction .add(Byte.SIZE); public static final CancelTcpipForwardRequestHandler INSTANCE = new CancelTcpipForwardRequestHandler( new ForwardedPortsCollection()); private ForwardedPortsCollection forwardedPorts; public CancelTcpipForwardRequestHandler(ForwardedPortsCollection forwardedPorts) { super(); this.forwardedPorts = forwardedPorts; } @Override public Result process( ConnectionService connectionService, String request, boolean wantReply, Buffer buffer) throws Exception { if (!REQUEST.equals(request)) { return super.process(connectionService, request, wantReply, buffer); } String address = buffer.getString(); int port = buffer.getInt(); SshdSocketAddress socketAddress = new SshdSocketAddress(address, port); if (log.isDebugEnabled()) { log.debug("process({})[{}] {} reply={}", connectionService, request, socketAddress, wantReply); } Forwarder forwarder = Objects.requireNonNull(connectionService.getForwarder(), "No TCP/IP forwarder"); // local ports can be chosen dynamically so we have to keep track of which // remote // port the local port is associated with. ForwardedPort forwardedPort = this.forwardedPorts .stream() .filter(p -> p.getRemotePort() == port) .findFirst() .orElse(null); if (forwardedPort != null) { SshdSocketAddress localAddress = new SshdSocketAddress(address, port); forwarder.localPortForwardingCancelled(localAddress); } else { return Result.ReplyFailure; } if (wantReply) { Session session = connectionService.getSession(); buffer = session.createBuffer(SshConstants.SSH_MSG_REQUEST_SUCCESS, Integer.BYTES); buffer.putInt(port); session.writePacket(buffer); } forwardedPorts.removePort(forwardedPort); return Result.Replied; } } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/connections/ForwardedPort.java000066400000000000000000000006751450757157500316710ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.connections; public class ForwardedPort { private int localPort; private int remotePort; public ForwardedPort(int localPort, int remotePort) { this.localPort = localPort; this.remotePort = remotePort; } public int getLocalPort() { return localPort; } public int getRemotePort() { return remotePort; } } ForwardedPortEventListener.java000066400000000000000000000005401450757157500343110ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/connections// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.connections; import java.util.EventListener; public interface ForwardedPortEventListener extends EventListener { default void onForwardedPortAdded(ForwardedPort port) { }; default void onForwardedPortRemoved(ForwardedPort port) { }; } ForwardedPortsCollection.java000066400000000000000000000052661450757157500340120ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/connections// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.connections; import java.util.AbstractList; import java.util.ArrayList; import java.util.List; /** * An unmodifiable list of {@link ForwardedPort}s. * Also keeps a list of {@link ForwardedPortEventListener}s to be called when a * port is added or removed. */ public class ForwardedPortsCollection extends AbstractList { private List listeners = new ArrayList(); private List ports = new ArrayList(); public ForwardedPort get(int i) { return ports.get(i); } public int size() { return ports.size(); } /** * Adds the specified {@link ForwardedPortEventListener} which should implement * onForwardedPortAdded and/or onForwardedPortRemoved. * *
   * setForwardedPortEventListener(new ForwardedPortEventListener() {
   *   @Override
   *   public void onForwardedPortAdded(ForwardedPort port) {
   *     // Do something when the port is added.
   *   };
   *
   *   public void onForwardedPortAdded(ForwardedPort port) {
   *     // Do something when the port is removed.
   *   };
   * });
   * 
* * @param listener the {@link ForwardedPortEventListener} to add. */ public void addListener(ForwardedPortEventListener listener) { if (!listeners.contains(listener)) { listeners.add(listener); } } /** * Removes the specified {@link ForwardedPortEventListener} listener. * * @param listener the {@link ForwardedPortEventListener} to remove. */ public void removeListener(ForwardedPortEventListener listener) { listeners.remove(listener); } /** * Internal. * Adds the specified {@link ForwardedPort} and calls listeners that provice * onForwardedPortAdded. * * @param port the {@link ForwardedPort} port to add. */ void addPort(ForwardedPort port) { if (ports.stream().anyMatch(p -> p.getRemotePort() == port.getRemotePort())) { throw new IllegalStateException("Port has already been added to the collection."); } ports.add(port); for (ForwardedPortEventListener listener : listeners) { listener.onForwardedPortAdded(port); } } /** * Internal. * Removes the specified {@link ForwardedPort} and notifies listeners that * provide onForwardedPortRemoved. * * @param port the {@link ForwardedPort} port to remove. */ void removePort(ForwardedPort port) { if (ports.removeIf(p -> p.getRemotePort() == port.getRemotePort())) { for (ForwardedPortEventListener listener : listeners) { listener.onForwardedPortRemoved(port); } } } } TcpipForwardRequestHandler.java000066400000000000000000000072001450757157500342720ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/connections// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.connections; import java.net.BindException; import java.util.Objects; import java.util.function.IntUnaryOperator; import org.apache.sshd.common.SshConstants; import org.apache.sshd.common.forward.Forwarder; import org.apache.sshd.common.session.ConnectionService; import org.apache.sshd.common.session.Session; import org.apache.sshd.common.session.helpers.AbstractConnectionServiceRequestHandler; import org.apache.sshd.common.util.buffer.Buffer; import org.apache.sshd.common.util.functors.Int2IntFunction; import org.apache.sshd.common.util.net.SshdSocketAddress; /** * Handler for "tcpip-forward" global request. * *

* Modified from org.apache.sshd.server.global.TcpipForwardRequestHandler to * track the requested * port since the default implementation only keeps track of the local port. *

*/ class TcpipForwardRequestHandler extends AbstractConnectionServiceRequestHandler { public static final String REQUEST = "tcpip-forward"; /** * Default growth factor function used to resize response buffers. */ public static final IntUnaryOperator RESPONSE_BUFFER_GROWTH_FACTOR = Int2IntFunction.add( Byte.SIZE); public static final TcpipForwardRequestHandler INSTANCE = new TcpipForwardRequestHandler( new ForwardedPortsCollection()); public ForwardedPortsCollection forwardedPorts; public TcpipForwardRequestHandler(ForwardedPortsCollection forwardedPorts) { super(); this.forwardedPorts = forwardedPorts; } @Override public Result process( ConnectionService connectionService, String request, boolean wantReply, Buffer buffer) throws Exception { if (!REQUEST.equals(request)) { return super.process(connectionService, request, wantReply, buffer); } Forwarder forwarder = Objects.requireNonNull( connectionService.getForwarder(), "No TCP/IP forwarder"); String address = buffer.getString(); int requested = buffer.getInt(); int port = requested; SshdSocketAddress socketAddress = null; SshdSocketAddress bound = null; // If the port is in use we will get a bind exception so we increment the port // until we succeed or run out of attempts. for (int attempt = 0; attempt < 10; attempt++) { port = port + attempt; socketAddress = new SshdSocketAddress(address, port); try { bound = forwarder.localPortForwardingRequested(socketAddress); break; } catch (BindException e) { if (log.isDebugEnabled()) { log.debug("Caught BindException {} attempting to connect to port {}, " + "incrementing port and retrying.", e, port); } continue; } } // If bound is still null, try wildcard port. if (bound == null) { port = 0; socketAddress = new SshdSocketAddress(address, port); bound = forwarder.localPortForwardingRequested(socketAddress); } if (log.isDebugEnabled()) { log.debug("process({})[{}][want-reply-{}] {} => {}", connectionService, request, wantReply, socketAddress, bound); } // If we somehow still failed to bind to a port after multiple tries, reply with // failure. if (bound == null) { return Result.ReplyFailure; } port = bound.getPort(); if (wantReply) { Session session = connectionService.getSession(); buffer = session.createBuffer(SshConstants.SSH_MSG_REQUEST_SUCCESS, Integer.BYTES); buffer.putInt(port); session.writePacket(buffer); } forwardedPorts.addPort(new ForwardedPort(port, requested)); return Result.Replied; } } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/connections/TunnelClient.java000066400000000000000000000022361450757157500315060ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.connections; import com.microsoft.tunnels.contracts.Tunnel; import java.util.concurrent.CompletableFuture; /** * Interface for a client capable of making a connection to a tunnel and * forwarding ports over the tunnel. */ public interface TunnelClient { ForwardedPortsCollection getForwardedPorts(); /** * Connects to the specified tunnel. * * @param tunnel Tunnel to connect to. * @return A future that completes when the connection succeeds or fails. */ CompletableFuture connectAsync(Tunnel tunnel); /** * Connects to the specified tunnel and host. * * @param tunnel Tunnel to connect to. * @param hostId ID of the host connected to the tunnel. * @return A future that completes when the connection succeeds or fails. */ CompletableFuture connectAsync( Tunnel tunnel, String hostId); /** * Sends a request to the host to refresh ports that were updated using the management API, * and waits for the refresh to complete. */ CompletableFuture refreshPortsAsync(); void stop(); } TunnelConnectionException.java000066400000000000000000000010711450757157500341630ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/connections// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.connections; /** * Exception thrown when a host or client failed to connect to a tunnel. */ public class TunnelConnectionException extends RuntimeException { public TunnelConnectionException() { } public TunnelConnectionException(String message) { super(message); } public TunnelConnectionException(Throwable cause) { super(cause); } public TunnelConnectionException(String message, Throwable cause) { super(message, cause); } } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/connections/TunnelRelayTunnelClient.java000066400000000000000000000160361450757157500336740ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.connections; import com.microsoft.tunnels.contracts.Tunnel; import com.microsoft.tunnels.contracts.TunnelConnectionMode; import com.microsoft.tunnels.contracts.TunnelEndpoint; import com.microsoft.tunnels.contracts.TunnelRelayTunnelEndpoint; import com.microsoft.tunnels.websocket.WebSocketServiceFactoryFactory; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import org.apache.sshd.client.SshClient; import org.apache.sshd.client.session.ClientSession; import org.apache.sshd.common.SshConstants; import org.apache.sshd.common.channel.RequestHandler; import org.apache.sshd.common.session.ConnectionService; import org.apache.sshd.server.forward.AcceptAllForwardingFilter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Tunnel client implementation that connects via a tunnel relay. */ public class TunnelRelayTunnelClient implements TunnelClient { private static final Duration sshSessionTimeout = Duration.ofSeconds(20); private static final Duration sshAuthTimeout = Duration.ofSeconds(10); private static final Logger logger = LoggerFactory.getLogger(TunnelRelayTunnelClient.class); private ClientSession session = null; private SshClient sshClient = null; /** *

* By default the ssh library will only start local port forwarding for a * requested port. *

*

* We provide custom implementations of TcpipForwardRequestHandler and * CancelTcpipForwardHandler that allow different local ports to be selected if * the requested port is in * use. *

*

* That tracking is mapped here since TunnelClient consumers also have reason to * track port added/removed events. *

*/ private ForwardedPortsCollection forwardedPorts = new ForwardedPortsCollection(); public TunnelRelayTunnelClient() { } @Override public ForwardedPortsCollection getForwardedPorts() { return forwardedPorts; } @Override public CompletableFuture connectAsync(Tunnel tunnel) { return connectAsync(tunnel, null); } @Override public CompletableFuture connectAsync( Tunnel tunnel, String hostId) { if (session != null) { throw new IllegalStateException( "Already connected. Use separate instances to connect to multiple tunnels."); } if (tunnel.endpoints == null || tunnel.endpoints.length == 0) { throw new IllegalStateException( "No hosts are currently accepting connections for the tunnel."); } var endpoint = (TunnelRelayTunnelEndpoint) groupEndpoints(tunnel, hostId).stream() .filter((e) -> e.connectionMode == TunnelConnectionMode.TunnelRelay) .findFirst().orElseThrow(() -> { throw new IllegalStateException( "The specified host is not currently accepting connections to the tunnel."); }); sshClient = createConfiguredSshClient(tunnel, endpoint); logger.info("Connecting to client tunnel relay " + endpoint.clientRelayUri); return CompletableFuture.runAsync(() -> { sshClient.start(); try { /* * The SshClient API doesn't have a connect method that doesn't require a * username/host/port. * However we are using a custom connector (WebSocketConnector) which is * ultimately what starts * the session, and it only uses the username. */ session = sshClient.connect("tunnel@host:1") .verify(sshSessionTimeout) .getSession(); } catch (IOException e) { throw new TunnelConnectionException("Error verifying the ssh session.", e); } try { session.auth().verify(sshAuthTimeout); } catch (IOException e) { throw new TunnelConnectionException("Error authenticating the ssh session.", e); } }); } @Override public CompletableFuture refreshPortsAsync() { return CompletableFuture.runAsync(() -> { var refreshPortsRequestType = "RefreshPorts"; var requestBuffer = this.session.createBuffer(SshConstants.SSH_MSG_GLOBAL_REQUEST); requestBuffer.putString(refreshPortsRequestType); requestBuffer.putBoolean(true); // WantReply try { this.session.request(refreshPortsRequestType, requestBuffer, sshSessionTimeout); } catch (IOException e) { throw new TunnelConnectionException("Error refreshing ports.", e); } }); } /** * Creates an {@link SshClient} and configures it to connect to the endpoint's * clientRelayUri. * * @return the created {@link SshClient}. */ private SshClient createConfiguredSshClient(Tunnel tunnel, TunnelRelayTunnelEndpoint endpoint) { SshClient client = SshClient.setUpDefaultClient(); // Allows filtering based on request type or address. Currently allows all // requests. client.setForwardingFilter(AcceptAllForwardingFilter.INSTANCE); try { String accessToken = tunnel.accessTokens.get("connect"); client.setIoServiceFactoryFactory( new WebSocketServiceFactoryFactory(new URI(endpoint.clientRelayUri), accessToken)); } catch (URISyntaxException e) { // This would likely only occur as the result of manually created tunnel being // passed rather than one retrieved from the service. throw new IllegalArgumentException( "Error parsing tunnel clientRelayUri. " + "Check that the tunnel endpoint is correct: " + endpoint.clientRelayUri, e); } // Add the handler for tcpip-forward requests. getGlobalRequestHandlers returns // an unmodifiable collection so we have to copy it. List> oldGlobals = client.getGlobalRequestHandlers(); List> newGlobals = new ArrayList<>(); if (oldGlobals.size() > 0) { newGlobals.addAll(oldGlobals); } newGlobals.add(new TcpipForwardRequestHandler(forwardedPorts)); newGlobals.add(new CancelTcpipForwardRequestHandler(forwardedPorts)); client.setGlobalRequestHandlers(newGlobals); return client; } private List groupEndpoints(Tunnel tunnel, String hostId) { Map> endpointGroups = Arrays.asList(tunnel.endpoints) .stream().collect(Collectors.groupingBy(endpoint -> endpoint.hostId)); if (hostId != null) { return endpointGroups.get(hostId); } else if (endpointGroups.size() > 1) { throw new IllegalStateException( "There are multiple hosts for the tunnel. Specify a host ID to connect to."); } else { return endpointGroups.values().stream().findFirst().orElseThrow(() -> { throw new IllegalStateException( "No host is currently accepting connections to the tunnel."); }); } } @Override public void stop() { this.sshClient.stop(); } } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/000077500000000000000000000000001450757157500257125ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/ClusterDetails.java000066400000000000000000000017551450757157500315140ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/ClusterDetails.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; /** * Details of a tunneling service cluster. Each cluster represents an instance of the * tunneling service running in a particular Azure region. New tunnels are created in the * current region unless otherwise specified. */ public class ClusterDetails { ClusterDetails (String clusterId, String uri, String azureLocation) { this.clusterId = clusterId; this.uri = uri; this.azureLocation = azureLocation; } /** * A cluster identifier based on its region. */ @Expose public final String clusterId; /** * The URI of the service cluster. */ @Expose public final String uri; /** * The Azure location of the cluster. */ @Expose public final String azureLocation; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/ErrorCodes.java000066400000000000000000000012231450757157500306220ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/ErrorCodes.cs package com.microsoft.tunnels.contracts; /** * Error codes for ErrorDetail.Code and `x-ms-error-code` header. */ public class ErrorCodes { /** * Operation timed out. */ public static final String timeout = "Timeout"; /** * Operation cannot be performed because the service is not available. */ public static final String serviceUnavailable = "ServiceUnavailable"; /** * Internal error. */ public static final String internalError = "InternalError"; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/ErrorDetail.java000066400000000000000000000021001450757157500307620ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/ErrorDetail.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; import com.google.gson.annotations.SerializedName; /** * The top-level error object whose code matches the x-ms-error-code response header */ public class ErrorDetail { /** * One of a server-defined set of error codes defined in {@link ErrorCodes}. */ @Expose public String code; /** * A human-readable representation of the error. */ @Expose public String message; /** * The target of the error. */ @Expose public String target; /** * An array of details about specific errors that led to this reported error. */ @Expose public ErrorDetail[] details; /** * An object containing more specific information than the current object about the * error. */ @SerializedName("innererror") @Expose public InnerErrorDetail innerError; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/InnerErrorDetail.java000066400000000000000000000015001450757157500317610ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/InnerErrorDetail.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; import com.google.gson.annotations.SerializedName; /** * An object containing more specific information than the current object about the error. */ public class InnerErrorDetail { /** * A more specific error code than was provided by the containing error. One of a * server-defined set of error codes in {@link ErrorCodes}. */ @Expose public String code; /** * An object containing more specific information than the current object about the * error. */ @SerializedName("innererror") @Expose public InnerErrorDetail innerError; } LocalNetworkTunnelEndpoint.java000066400000000000000000000022751450757157500337770ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/LocalNetworkTunnelEndpoint.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; /** * Parameters for connecting to a tunnel via a local network connection. * * While a direct connection is technically not "tunneling", tunnel hosts may accept * connections via the local network as an optional more-efficient alternative to a relay. */ public class LocalNetworkTunnelEndpoint extends TunnelEndpoint { /** * Gets or sets a list of IP endpoints where the host may accept connections. * * A host may accept connections on multiple IP endpoints simultaneously if there are * multiple network interfaces on the host system and/or if the host supports both * IPv4 and IPv6. Each item in the list is a URI consisting of a scheme (which gives * an indication of the network connection protocol), an IP address (IPv4 or IPv6) and * a port number. The URIs do not typically include any paths, because the connection * is not normally HTTP-based. */ @Expose public String[] hostEndpoints; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/NamedRateStatus.java000066400000000000000000000006431450757157500316240ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/NamedRateStatus.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; /** * A named {@link RateStatus}. */ public class NamedRateStatus extends RateStatus { /** * The name of the rate status. */ @Expose public String name; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/ProblemDetails.java000066400000000000000000000020321450757157500314600ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/ProblemDetails.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; import java.util.Map; /** * Structure of error details returned by the tunnel service, including validation errors. * * This object may be returned with a response status code of 400 (or other 4xx code). It * is compatible with RFC 7807 Problem Details (https://tools.ietf.org/html/rfc7807) and * https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.problemdetails but * doesn't require adding a dependency on that package. */ public class ProblemDetails { /** * Gets or sets the error title. */ @Expose public String title; /** * Gets or sets the error detail. */ @Expose public String detail; /** * Gets or sets additional details about individual request properties. */ @Expose public Map errors; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/RateStatus.java000066400000000000000000000015241450757157500306560ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/RateStatus.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; /** * Current value and limit information for a rate-limited operation related to a tunnel or * port. */ public class RateStatus extends ResourceStatus { /** * Gets or sets the length of each period, in seconds, over which the rate is * measured. * * For rates that are limited by month (or billing period), this value may represent * an estimate, since the actual duration may vary by the calendar. */ @Expose public int periodSeconds; /** * Gets or sets the unix time in seconds when this status will be reset. */ @Expose public long resetTime; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/ResourceStatus.java000066400000000000000000000017501450757157500315530ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/ResourceStatus.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; /** * Current value and limit for a limited resource related to a tunnel or tunnel port. */ public class ResourceStatus { /** * Gets or sets the current value. */ @Expose public long current; /** * Gets or sets the limit enforced by the service, or null if there is no limit. * * Any requests that would cause the limit to be exceeded may be denied by the * service. For HTTP requests, the response is generally a 403 Forbidden status, with * details about the limit in the response body. */ @Expose public long limit; /** * Gets or sets an optional source of the {@link ResourceStatus#limit}, or null if * there is no limit. */ @Expose public String limitSource; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/ServiceVersionDetails.java000066400000000000000000000017631450757157500330400ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/ServiceVersionDetails.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; /** * Data contract for service version details. */ public class ServiceVersionDetails { /** * Gets or sets the version of the service. E.g. "1.0.6615.53976". The version * corresponds to the build number. */ @Expose public String version; /** * Gets or sets the commit ID of the service. */ @Expose public String commitId; /** * Gets or sets the commit date of the service. */ @Expose public String commitDate; /** * Gets or sets the cluster ID of the service that handled the request. */ @Expose public String clusterId; /** * Gets or sets the Azure location of the service that handled the request. */ @Expose public String azureLocation; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/Tunnel.java000066400000000000000000000056731450757157500300350ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/Tunnel.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; import java.util.Date; import java.util.Map; /** * Data contract for tunnel objects managed through the tunnel service REST API. */ public class Tunnel { /** * Gets or sets the ID of the cluster the tunnel was created in. */ @Expose public String clusterId; /** * Gets or sets the generated ID of the tunnel, unique within the cluster. */ @Expose public String tunnelId; /** * Gets or sets the optional short name (alias) of the tunnel. * * The name must be globally unique within the parent domain, and must be a valid * subdomain. */ @Expose public String name; /** * Gets or sets the description of the tunnel. */ @Expose public String description; /** * Gets or sets the tags of the tunnel. */ @Expose public String[] tags; /** * Gets or sets the optional parent domain of the tunnel, if it is not using the * default parent domain. */ @Expose public String domain; /** * Gets or sets a dictionary mapping from scopes to tunnel access tokens. */ @Expose public Map accessTokens; /** * Gets or sets access control settings for the tunnel. * * See {@link TunnelAccessControl} documentation for details about the access control * model. */ @Expose public TunnelAccessControl accessControl; /** * Gets or sets default options for the tunnel. */ @Expose public TunnelOptions options; /** * Gets or sets current connection status of the tunnel. */ @Expose public TunnelStatus status; /** * Gets or sets an array of endpoints where hosts are currently accepting client * connections to the tunnel. */ @Expose public TunnelEndpoint[] endpoints; /** * Gets or sets a list of ports in the tunnel. * * This optional property enables getting info about all ports in a tunnel at the same * time as getting tunnel info, or creating one or more ports at the same time as * creating a tunnel. It is omitted when listing (multiple) tunnels, or when updating * tunnel properties. (For the latter, use APIs to create/update/delete individual * ports instead.) */ @Expose public TunnelPort[] ports; /** * Gets or sets the time in UTC of tunnel creation. */ @Expose public Date created; /** * Gets or the time the tunnel will be deleted if it is not used or updated. */ @Expose public Date expiration; /** * Gets or the custom amount of time the tunnel will be valid if it is not used or * updated in seconds. */ @Expose public int customExpiration; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelAccessControl.java000066400000000000000000000033141450757157500325060ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelAccessControl.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; import java.util.Collection; /** * Data contract for access control on a {@link Tunnel} or {@link TunnelPort}. * * Tunnels and tunnel ports can each optionally have an access-control property set on * them. An access-control object contains a list (ACL) of entries (ACEs) that specify the * access scopes granted or denied to some subjects. Tunnel ports inherit the ACL from the * tunnel, though ports may include ACEs that augment or override the inherited rules. * Currently there is no capability to define "roles" for tunnel access (where a role * specifies a set of related access scopes), and assign roles to users. That feature may * be added in the future. (It should be represented as a separate `RoleAssignments` * property on this class.) */ public class TunnelAccessControl { /** * Gets or sets the list of access control entries. * * The order of entries is significant: later entries override earlier entries that * apply to the same subject. However, deny rules are always processed after allow * rules, therefore an allow rule cannot override a deny rule for the same subject. */ @Expose public TunnelAccessControlEntry[] entries; /** * Checks that all items in an array of scopes are valid. */ public static void validateScopes(Collection scopes, Collection validScopes, boolean allowMultiple) { TunnelAccessControlStatics.validateScopes(scopes, validScopes, allowMultiple); } } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelAccessControlEntry.java000066400000000000000000000122031450757157500335250ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelAccessControlEntry.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; import java.util.Date; /** * Data contract for an access control entry on a {@link Tunnel} or {@link TunnelPort}. * * An access control entry (ACE) grants or denies one or more access scopes to one or more * subjects. Tunnel ports inherit access control entries from their tunnel, and they may * have additional port-specific entries that augment or override those access rules. */ public class TunnelAccessControlEntry { /** * Gets or sets the access control entry type. */ @Expose public TunnelAccessControlEntryType type; /** * Gets or sets the provider of the subjects in this access control entry. The * provider impacts how the subject identifiers are resolved and displayed. The * provider may be an identity provider such as AAD, or a system or standard such as * "ssh" or "ipv4". * * For user, group, or org ACEs, this value is the name of the identity provider of * the user/group/org IDs. It may be one of the well-known provider names in {@link * TunnelAccessControlEntry#providers}, or (in the future) a custom identity provider. * For public key ACEs, this value is the type of public key, e.g. "ssh". For IP * address range ACEs, this value is the IP address version, "ipv4" or "ipv6", or * "service-tag" if the range is defined by an Azure service tag. For anonymous ACEs, * this value is null. */ @Expose public String provider; /** * Gets or sets a value indicating whether this is an access control entry on a tunnel * port that is inherited from the tunnel's access control list. */ @Expose public boolean isInherited; /** * Gets or sets a value indicating whether this entry is a deny rule that blocks * access to the specified users. Otherwise it is an allow rule. * * All deny rules (including inherited rules) are processed after all allow rules. * Therefore a deny ACE cannot be overridden by an allow ACE that is later in the list * or on a more-specific resource. In other words, inherited deny ACEs cannot be * overridden. */ @Expose public boolean isDeny; /** * Gets or sets a value indicating whether this entry applies to all subjects that are * NOT in the {@link TunnelAccessControlEntry#subjects} list. * * Examples: an inverse organizations ACE applies to all users who are not members of * the listed organization(s); an inverse anonymous ACE applies to all authenticated * users; an inverse IP address ranges ACE applies to all clients that are not within * any of the listed IP address ranges. The inverse option is often useful in policies * in combination with {@link TunnelAccessControlEntry#isDeny}, for example a policy * could deny access to users who are not members of an organization or are outside of * an IP address range, effectively blocking any tunnels from allowing outside access * (because inherited deny ACEs cannot be overridden). */ @Expose public boolean isInverse; /** * Gets or sets an optional organization context for all subjects of this entry. The * use and meaning of this value depends on the {@link TunnelAccessControlEntry#type} * and {@link TunnelAccessControlEntry#provider} of this entry. * * For AAD users and group ACEs, this value is the AAD tenant ID. It is not currently * used with any other types of ACEs. */ @Expose public String organization; /** * Gets or sets the subjects for the entry, such as user or group IDs. The format of * the values depends on the {@link TunnelAccessControlEntry#type} and {@link * TunnelAccessControlEntry#provider} of this entry. */ @Expose public String[] subjects; /** * Gets or sets the access scopes that this entry grants or denies to the subjects. * * These must be one or more values from {@link TunnelAccessScopes}. */ @Expose public String[] scopes; /** * Gets or sets the expiration for an access control entry. * * If no value is set then this value is null. */ @Expose public Date expiration; /** * Constants for well-known identity providers. */ public static class Providers { /** * Microsoft (AAD) identity provider. */ public static final String microsoft = "microsoft"; /** * GitHub identity provider. */ public static final String gitHub = "github"; /** * SSH public keys. */ public static final String ssh = "ssh"; /** * IPv4 addresses. */ public static final String iPv4 = "ipv4"; /** * IPv6 addresses. */ public static final String iPv6 = "ipv6"; /** * Service tags. */ public static final String serviceTag = "service-tag"; } } TunnelAccessControlEntryType.java000066400000000000000000000036041450757157500343150ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelAccessControlEntryType.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.SerializedName; /** * Specifies the type of {@link TunnelAccessControlEntry}. */ public enum TunnelAccessControlEntryType { /** * Uninitialized access control entry type. */ @SerializedName("None") None, /** * The access control entry refers to all anonymous users. */ @SerializedName("Anonymous") Anonymous, /** * The access control entry is a list of user IDs that are allowed (or denied) access. */ @SerializedName("Users") Users, /** * The access control entry is a list of groups IDs that are allowed (or denied) * access. */ @SerializedName("Groups") Groups, /** * The access control entry is a list of organization IDs that are allowed (or denied) * access. * * All users in the organizations are allowed (or denied) access, unless overridden by * following group or user rules. */ @SerializedName("Organizations") Organizations, /** * The access control entry is a list of repositories. Users are allowed access to the * tunnel if they have access to the repo. */ @SerializedName("Repositories") Repositories, /** * The access control entry is a list of public keys. Users are allowed access if they * can authenticate using a private key corresponding to one of the public keys. */ @SerializedName("PublicKeys") PublicKeys, /** * The access control entry is a list of IP address ranges that are allowed (or * denied) access to the tunnel. Ranges can be IPv4, IPv6, or Azure service tags. */ @SerializedName("IPAddressRanges") IPAddressRanges, } TunnelAccessControlStatics.java000066400000000000000000000027601450757157500337660ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.contracts; import java.util.Arrays; import java.util.Collection; import java.util.stream.Collectors; import org.apache.maven.shared.utils.StringUtils; class TunnelAccessControlStatics { static void validateScopes( Collection scopes, Collection validScopes, boolean allowMultiple) { if (scopes == null) { throw new IllegalArgumentException("scopes must not be null"); } if (allowMultiple) { scopes = scopes.stream().flatMap((s) -> Arrays.stream(s.split(" "))) .collect(Collectors.toList()); } var allScopes = Arrays.asList(new String[] { TunnelAccessScopes.connect, TunnelAccessScopes.create, TunnelAccessScopes.host, TunnelAccessScopes.inspect, TunnelAccessScopes.manage, TunnelAccessScopes.managePorts }); scopes.forEach(scope -> { if (StringUtils.isBlank(scope)) { throw new IllegalArgumentException("Tunnel access scopes include a null/empty item."); } else if (!allScopes.contains(scope)) { throw new IllegalArgumentException("Invalid tunnel access scope: " + scope); } }); if (validScopes != null) { scopes.forEach(scope -> { if (!validScopes.contains(scope)) { throw new IllegalArgumentException( "Tunnel access scope is invalid for current request: " + scope); } }); } } } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelAccessScopes.java000066400000000000000000000032351450757157500323240ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelAccessScopes.cs package com.microsoft.tunnels.contracts; /** * Defines scopes for tunnel access tokens. * * A tunnel access token with one or more of these scopes typically also has cluster ID * and tunnel ID claims that limit the access scope to a specific tunnel, and may also * have one or more port claims that further limit the access to particular ports of the * tunnel. */ public class TunnelAccessScopes { /** * Allows creating tunnels. This scope is valid only in policies at the global, * domain, or organization level; it is not relevant to an already-created tunnel or * tunnel port. (Creation of ports requires "manage" or "host" access to the tunnel.) */ public static final String create = "create"; /** * Allows management operations on tunnels and tunnel ports. */ public static final String manage = "manage"; /** * Allows management operations on all ports of a tunnel, but does not allow updating * any other tunnel properties or deleting the tunnel. */ public static final String managePorts = "manage:ports"; /** * Allows accepting connections on tunnels as a host. Includes access to update tunnel * endpoints and ports. */ public static final String host = "host"; /** * Allows inspecting tunnel connection activity and data. */ public static final String inspect = "inspect"; /** * Allows connecting to tunnels or ports as a client. */ public static final String connect = "connect"; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelAccessSubject.java000066400000000000000000000033401450757157500324640ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelAccessSubject.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; /** * Properties about a subject of a tunnel access control entry (ACE), used when resolving * subject names to IDs when creating new ACEs, or formatting subject IDs to names when * displaying existing ACEs. */ public class TunnelAccessSubject { /** * Gets or sets the type of subject, e.g. user, group, or organization. */ @Expose public TunnelAccessControlEntryType type; /** * Gets or sets the subject ID. * * The ID is typically a guid or integer that is unique within the scope of the * identity provider or organization, and never changes for that subject. */ @Expose public String id; /** * Gets or sets the subject organization ID, which may be required if an organization * is not implied by the authentication context. */ @Expose public String organizationId; /** * Gets or sets the partial or full subject name. * * When resolving a subject name to ID, a partial name may be provided, and the full * name is returned if the partial name was successfully resolved. When formatting a * subject ID to name, the full name is returned if the ID was found. */ @Expose public String name; /** * Gets or sets an array of possible subject matches, if a partial name was provided * and did not resolve to a single subject. * * This property applies only when resolving subject names to IDs. */ @Expose public TunnelAccessSubject[] matches; } TunnelAuthenticationSchemes.java000066400000000000000000000015371450757157500341610ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelAuthenticationSchemes.cs package com.microsoft.tunnels.contracts; /** * Defines string constants for authentication schemes supported by tunnel service APIs. */ public class TunnelAuthenticationSchemes { /** * Authentication scheme for AAD (or Microsoft account) access tokens. */ public static final String aad = "aad"; /** * Authentication scheme for GitHub access tokens. */ public static final String gitHub = "github"; /** * Authentication scheme for tunnel access tokens. */ public static final String tunnel = "tunnel"; /** * Authentication scheme for tunnelPlan access tokens. */ public static final String tunnelPlan = "tunnelplan"; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelConnectionMode.java000066400000000000000000000016251450757157500326530ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelConnectionMode.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.SerializedName; /** * Specifies the connection protocol / implementation for a tunnel. * * Depending on the connection mode, hosts or clients might need to use different * authentication and connection protocols. */ public enum TunnelConnectionMode { /** * Connect directly to the host over the local network. * * While it's technically not "tunneling", this mode may be combined with others to * enable choosing the most efficient connection mode available. */ @SerializedName("LocalNetwork") LocalNetwork, /** * Use the tunnel service's integrated relay function. */ @SerializedName("TunnelRelay") TunnelRelay, } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelConstraints.java000066400000000000000000000317461450757157500322650ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelConstraints.cs package com.microsoft.tunnels.contracts; import java.util.regex.Pattern; /** * Tunnel constraints. */ public class TunnelConstraints { /** * Min length of tunnel cluster ID. */ public static final int clusterIdMinLength = 3; /** * Max length of tunnel cluster ID. */ public static final int clusterIdMaxLength = 12; /** * Length of V1 tunnel id. */ public static final int oldTunnelIdLength = 8; /** * Min length of V2 tunnelId. */ public static final int newTunnelIdMinLength = 3; /** * Max length of V2 tunnelId. */ public static final int newTunnelIdMaxLength = 60; /** * Length of a tunnel alias. */ public static final int tunnelAliasLength = 8; /** * Min length of tunnel name. */ public static final int tunnelNameMinLength = 3; /** * Max length of tunnel name. */ public static final int tunnelNameMaxLength = 60; /** * Max length of tunnel or port description. */ public static final int descriptionMaxLength = 400; /** * Min length of a single tunnel or port tag. */ public static final int tagMinLength = 1; /** * Max length of a single tunnel or port tag. */ public static final int tagMaxLength = 50; /** * Maximum number of tags that can be applied to a tunnel or port. */ public static final int maxTags = 100; /** * Min length of a tunnel domain. */ public static final int tunnelDomainMinLength = 4; /** * Max length of a tunnel domain. */ public static final int tunnelDomainMaxLength = 180; /** * Maximum number of items allowed in the tunnel ports array. The actual limit on * number of ports that can be created may be much lower, and may depend on various * resource limitations or policies. */ public static final int tunnelMaxPorts = 1000; /** * Maximum number of access control entries (ACEs) in a tunnel or tunnel port access * control list (ACL). */ public static final int accessControlMaxEntries = 40; /** * Maximum number of subjects (such as user IDs) in a tunnel or tunnel port access * control entry (ACE). */ public static final int accessControlMaxSubjects = 100; /** * Max length of an access control subject or organization ID. */ public static final int accessControlSubjectMaxLength = 200; /** * Max length of an access control subject name, when resolving names to IDs. */ public static final int accessControlSubjectNameMaxLength = 200; /** * Maximum number of scopes in an access control entry. */ public static final int accessControlMaxScopes = 10; /** * Regular expression that can match or validate tunnel cluster ID strings. * * Cluster IDs are alphanumeric; hyphens are not permitted. */ public static final String clusterIdPattern = "^(([a-z]{3,4}[0-9]{1,3})|asse|aue|brs|euw|use)$"; /** * Regular expression that can match or validate tunnel cluster ID strings. * * Cluster IDs are alphanumeric; hyphens are not permitted. */ public static final Pattern clusterIdRegex = java.util.regex.Pattern.compile(TunnelConstraints.clusterIdPattern); /** * Characters that are valid in tunnel IDs. Includes numbers and lowercase letters, * excluding vowels and 'y' (to avoid accidentally generating any random words). */ public static final String oldTunnelIdChars = "0123456789bcdfghjklmnpqrstvwxz"; /** * Regular expression that can match or validate tunnel ID strings. * * Tunnel IDs are fixed-length and have a limited character set of numbers and * lowercase letters (minus vowels and y). */ public static final String oldTunnelIdPattern = "[" + TunnelConstraints.oldTunnelIdChars + "]{8}"; /** * Regular expression that can match or validate tunnel ID strings. * * Tunnel IDs are fixed-length and have a limited character set of numbers and * lowercase letters (minus vowels and y). */ public static final Pattern oldTunnelIdRegex = java.util.regex.Pattern.compile(TunnelConstraints.oldTunnelIdPattern); /** * Characters that are valid in tunnel IDs. Includes numbers and lowercase letters, * excluding vowels and 'y' (to avoid accidentally generating any random words). */ public static final String newTunnelIdChars = "0123456789abcdefghijklmnopqrstuvwxyz-"; /** * Regular expression that can match or validate tunnel ID strings. * * Tunnel IDs are fixed-length and have a limited character set of numbers and * lowercase letters (minus vowels and y). */ public static final String newTunnelIdPattern = "[a-z0-9][a-z0-9-]{1,58}[a-z0-9]"; /** * Regular expression that can match or validate tunnel ID strings. * * Tunnel IDs are fixed-length and have a limited character set of numbers and * lowercase letters (minus vowels and y). */ public static final Pattern newTunnelIdRegex = java.util.regex.Pattern.compile(TunnelConstraints.newTunnelIdPattern); /** * Characters that are valid in tunnel IDs. Includes numbers and lowercase letters, * excluding vowels and 'y' (to avoid accidentally generating any random words). */ public static final String tunnelAliasChars = "0123456789bcdfghjklmnpqrstvwxz"; /** * Regular expression that can match or validate tunnel alias strings. * * Tunnel Aliases are fixed-length and have a limited character set of numbers and * lowercase letters (minus vowels and y). */ public static final String tunnelAliasPattern = "[" + TunnelConstraints.tunnelAliasChars + "]{3,60}"; /** * Regular expression that can match or validate tunnel alias strings. * * Tunnel Aliases are fixed-length and have a limited character set of numbers and * lowercase letters (minus vowels and y). */ public static final Pattern tunnelAliasRegex = java.util.regex.Pattern.compile(TunnelConstraints.tunnelAliasPattern); /** * Regular expression that can match or validate tunnel names. * * Tunnel names are alphanumeric and may contain hyphens. The pattern also allows an * empty string because tunnels may be unnamed. */ public static final String tunnelNamePattern = "([a-z0-9][a-z0-9-]{1,58}[a-z0-9])|(^$)"; /** * Regular expression that can match or validate tunnel names. * * Tunnel names are alphanumeric and may contain hyphens. The pattern also allows an * empty string because tunnels may be unnamed. */ public static final Pattern tunnelNameRegex = java.util.regex.Pattern.compile(TunnelConstraints.tunnelNamePattern); /** * Regular expression that can match or validate tunnel or port tags. */ public static final String tagPattern = "[\\w-=]{1,50}"; /** * Regular expression that can match or validate tunnel or port tags. */ public static final Pattern tagRegex = java.util.regex.Pattern.compile(TunnelConstraints.tagPattern); /** * Regular expression that can match or validate tunnel domains. * * The tunnel service may perform additional contextual validation at the time the * domain is registered. */ public static final String tunnelDomainPattern = "[0-9a-z][0-9a-z-.]{1,158}[0-9a-z]|(^$)"; /** * Regular expression that can match or validate tunnel domains. * * The tunnel service may perform additional contextual validation at the time the * domain is registered. */ public static final Pattern tunnelDomainRegex = java.util.regex.Pattern.compile(TunnelConstraints.tunnelDomainPattern); /** * Regular expression that can match or validate an access control subject or * organization ID. * * The : and / characters are allowed because subjects may include IP addresses and * ranges. The @ character is allowed because MSA subjects may be identified by email * address. */ public static final String accessControlSubjectPattern = "[0-9a-zA-Z-._:/@]{0,200}"; /** * Regular expression that can match or validate an access control subject or * organization ID. */ public static final Pattern accessControlSubjectRegex = java.util.regex.Pattern.compile(TunnelConstraints.accessControlSubjectPattern); /** * Regular expression that can match or validate an access control subject name, when * resolving subject names to IDs. * * Note angle-brackets are only allowed when they wrap an email address as part of a * formatted name with email. The service will block any other use of angle-brackets, * to avoid any XSS risks. */ public static final String accessControlSubjectNamePattern = "[ \\w\\d-.,/'\"_@()<>]{0,200}"; /** * Regular expression that can match or validate an access control subject name, when * resolving subject names to IDs. */ public static final Pattern accessControlSubjectNameRegex = java.util.regex.Pattern.compile(TunnelConstraints.accessControlSubjectNamePattern); /** * Validates and returns true if it is a valid cluster * ID, otherwise false. */ public static boolean isValidClusterId(String clusterId) { return TunnelConstraintsStatics.isValidClusterId(clusterId); } /** * Validates and returns true if it is a valid tunnel id, * otherwise, false. */ public static boolean isValidOldTunnelId(String tunnelId) { return TunnelConstraintsStatics.isValidOldTunnelId(tunnelId); } /** * Validates and returns true if it is a valid tunnel id, * otherwise, false. */ public static boolean isValidNewTunnelId(String tunnelId) { return TunnelConstraintsStatics.isValidNewTunnelId(tunnelId); } /** * Validates and returns true if it is a valid tunnel alias, * otherwise, false. */ public static boolean isValidTunnelAlias(String alias) { return TunnelConstraintsStatics.isValidTunnelAlias(alias); } /** * Validates and returns true if it is a valid tunnel * name, otherwise, false. */ public static boolean isValidTunnelName(String tunnelName) { return TunnelConstraintsStatics.isValidTunnelName(tunnelName); } /** * Validates and returns true if it is a valid tunnel tag, * otherwise, false. */ public static boolean isValidTag(String tag) { return TunnelConstraintsStatics.isValidTag(tag); } /** * Validates and returns true if it is a valid * tunnel id or name. */ public static boolean isValidTunnelIdOrName(String tunnelIdOrName) { return TunnelConstraintsStatics.isValidTunnelIdOrName(tunnelIdOrName); } /** * Validates and throws exception if it is null or not a * valid tunnel id. Returns back if it's a valid tunnel * id. */ public static String validateOldTunnelId(String tunnelId, String paramName) { return TunnelConstraintsStatics.validateOldTunnelId(tunnelId, paramName); } /** * Validates and throws exception if it is null or not a * valid tunnel id. Returns back if it's a valid tunnel * id. */ public static String validateNewOrOldTunnelId(String tunnelId, String paramName) { return TunnelConstraintsStatics.validateNewOrOldTunnelId(tunnelId, paramName); } /** * Validates and throws exception if it is null or not a * valid tunnel id. Returns back if it's a valid tunnel * id. */ public static String validateNewTunnelId(String tunnelId, String paramName) { return TunnelConstraintsStatics.validateNewTunnelId(tunnelId, paramName); } /** * Validates and throws exception if it is null or not * a valid tunnel id. Returns back if it's a valid * tunnel id. */ public static String validateTunnelAlias(String tunnelAlias, String paramName) { return TunnelConstraintsStatics.validateTunnelAlias(tunnelAlias, paramName); } /** * Validates and throws exception if it is null or * not a valid tunnel id or name. Returns back if * it's a valid tunnel id. */ public static String validateTunnelIdOrName(String tunnelIdOrName, String paramName) { return TunnelConstraintsStatics.validateTunnelIdOrName(tunnelIdOrName, paramName); } } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelConstraintsStatics.java000066400000000000000000000077611450757157500336200ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.contracts; import org.apache.maven.shared.utils.StringUtils; class TunnelConstraintsStatics { static boolean isValidClusterId(String clusterId) { if (StringUtils.isBlank(clusterId)) { return false; } var matcher = TunnelConstraints.clusterIdRegex.matcher(clusterId); return matcher.find() && matcher.start() == 0 && matcher.end() == clusterId.length(); } static boolean isValidOldTunnelId(String tunnelId) { if (tunnelId == null || tunnelId.length() != TunnelConstraints.oldTunnelIdLength) { return false; } var matcher = TunnelConstraints.oldTunnelIdRegex.matcher(tunnelId); return matcher.find() && matcher.start() == 0 && matcher.end() == tunnelId.length(); } static boolean isValidNewTunnelId(String tunnelId) { if (tunnelId == null || tunnelId.length() < TunnelConstraints.newTunnelIdMinLength || tunnelId.length() > TunnelConstraints.newTunnelIdMaxLength) { return false; } var matcher = TunnelConstraints.newTunnelIdRegex.matcher(tunnelId); return matcher.find() && matcher.start() == 0 && matcher.end() == tunnelId.length(); } static boolean isValidTunnelAlias(String alias) { if (alias == null || alias.length() != TunnelConstraints.tunnelAliasLength) { return false; } var matcher = TunnelConstraints.tunnelAliasRegex.matcher(alias); return matcher.find() && matcher.start() == 0 && matcher.end() == alias.length(); } static boolean isValidTunnelName(String tunnelName) { if (StringUtils.isBlank(tunnelName)) { return false; } var matcher = TunnelConstraints.tunnelNameRegex.matcher(tunnelName); return matcher.find() && matcher.start() == 0 && matcher.end() == tunnelName.length() && !isValidOldTunnelId(tunnelName); } static boolean isValidTag(String tag) { if (StringUtils.isBlank(tag)) { return false; } var matcher = TunnelConstraints.tagRegex.matcher(tag); return matcher.find() && matcher.start() == 0 && matcher.end() == tag.length(); } static boolean isValidTunnelIdOrName(String tunnelIdOrName) { if (StringUtils.isBlank(tunnelIdOrName)) { return false; } // Tunnel ID Regex is a subset of Tunnel name Regex var matcher = TunnelConstraints.tunnelNameRegex.matcher(tunnelIdOrName); return matcher.find() && matcher.start() == 0 && matcher.end() == tunnelIdOrName.length(); } static String validateOldTunnelId(String tunnelId, String paramName) { if (StringUtils.isBlank(tunnelId)) { throw new IllegalArgumentException(tunnelId); } if (!isValidOldTunnelId(tunnelId)) { throw new IllegalArgumentException("Invalid tunnel id: " + tunnelId); } return tunnelId; } static String validateNewTunnelId(String tunnelId, String paramName) { if (StringUtils.isBlank(tunnelId)) { throw new IllegalArgumentException(tunnelId); } if (!isValidNewTunnelId(tunnelId)) { throw new IllegalArgumentException("Invalid tunnel id: " + tunnelId); } return tunnelId; } static String validateNewOrOldTunnelId(String tunnelId, String paramName) { try { return validateNewTunnelId(tunnelId, paramName); } catch (IllegalArgumentException e) { return validateOldTunnelId(tunnelId, paramName); } } static String validateTunnelAlias(String alias, String paramName) { if (StringUtils.isBlank(alias)) { throw new IllegalArgumentException(alias); } if (!isValidTunnelAlias(alias)) { throw new IllegalArgumentException("Invalid tunnel id: " + alias); } return alias; } static String validateTunnelIdOrName(String tunnelIdOrName, String paramName) { if (StringUtils.isBlank(tunnelIdOrName)) { throw new IllegalArgumentException(tunnelIdOrName); } if (!isValidTunnelIdOrName(tunnelIdOrName)) { throw new IllegalArgumentException("Invalid tunnel id or name: " + tunnelIdOrName); } return tunnelIdOrName; } } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelContracts.java000066400000000000000000000044571450757157500317150ustar00rootroot00000000000000package com.microsoft.tunnels.contracts; import java.lang.reflect.Type; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonDeserializationContext; import com.google.gson.JsonDeserializer; import com.google.gson.JsonElement; import com.google.gson.JsonParseException; public class TunnelContracts { private TunnelContracts() {} private static Gson gson = createConfiguredGson(); public static Gson getGson() { return gson; } private static Gson createConfiguredGson() { // TODO - serializeNulls? var builder = new GsonBuilder() .excludeFieldsWithoutExposeAnnotation() .registerTypeAdapter(ResourceStatus.class, new ResourceStatusDeserializer()) .registerTypeAdapter(TunnelEndpoint.class, new TunnelEndpointDeserializer()); return builder.create(); } private static class TunnelEndpointDeserializer implements JsonDeserializer { private Gson gson; TunnelEndpointDeserializer() { this.gson = new Gson(); } @Override public TunnelEndpoint deserialize( JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { var endpointObject = json.getAsJsonObject(); var connectionMode = endpointObject.get("connectionMode").getAsString(); if (connectionMode.equals("TunnelRelay")) { return gson.fromJson(endpointObject, TunnelRelayTunnelEndpoint.class); } else if (connectionMode.equals("LocalNetwork")) { return gson.fromJson(endpointObject, LocalNetworkTunnelEndpoint.class); } else { throw new JsonParseException("Unable to parse TunnelEnpoint: " + endpointObject); } } } private static class ResourceStatusDeserializer implements JsonDeserializer { private Gson gson; ResourceStatusDeserializer() { this.gson = new Gson(); } @Override public ResourceStatus deserialize( JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { if (json.isJsonObject()) { return gson.fromJson(json, ResourceStatus.class); } else { var status = new ResourceStatus(); status.current = json.getAsLong(); return status; } } } } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelEndpoint.java000066400000000000000000000104021450757157500315200ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelEndpoint.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; import java.net.URI; /** * Base class for tunnel connection parameters. * * A tunnel endpoint specifies how and where hosts and clients can connect to a tunnel. * There is a subclass for each connection mode, each having different connection * parameters. A tunnel may have multiple endpoints for one host (or multiple hosts), and * clients can select their preferred endpoint(s) from those depending on network * environment or client capabilities. */ public class TunnelEndpoint { /** * Gets or sets the ID of this endpoint. */ @Expose public String id; /** * Gets or sets the connection mode of the endpoint. * * This property is required when creating or updating an endpoint. The subclass type * is also an indication of the connection mode, but this property is necessary to * determine the subclass type when deserializing. */ @Expose public TunnelConnectionMode connectionMode; /** * Gets or sets the ID of the host that is listening on this endpoint. * * This property is required when creating or updating an endpoint. If the host * supports multiple connection modes, the host's ID is the same for all the endpoints * it supports. However different hosts may simultaneously accept connections at * different endpoints for the same tunnel, if enabled in tunnel options. */ @Expose public String hostId; /** * Gets or sets an array of public keys, which can be used by clients to authenticate * the host. */ @Expose public String[] hostPublicKeys; /** * Gets or sets a string used to format URIs where a web client can connect to ports * of the tunnel. The string includes a {@link TunnelEndpoint#portToken} that must be * replaced with the actual port number. */ @Expose public String portUriFormat; /** * Gets or sets the URI where a web client can connect to the default port of the * tunnel. */ @Expose public String tunnelUri; /** * Gets or sets a string used to format ssh command where ssh client can connect to * shared ssh port of the tunnel. The string includes a {@link * TunnelEndpoint#portToken} that must be replaced with the actual port number. */ @Expose public String portSshCommandFormat; /** * Gets or sets the Ssh command where the Ssh client can connect to the default ssh * port of the tunnel. */ @Expose public String tunnelSshCommand; /** * Gets or sets the Ssh gateway public key which should be added to the * authorized_keys file so that tunnel service can connect to the shared ssh server. */ @Expose public String sshGatewayPublicKey; /** * Token included in {@link TunnelEndpoint#portUriFormat} and {@link * TunnelEndpoint#portSshCommandFormat} that is to be replaced by a specified port * number. */ public static final String portToken = "{port}"; /** * Gets a URI where a web client can connect to a tunnel port. * * Requests to the URI may result in HTTP 307 redirections, so the client may need to * follow the redirection in order to connect to the port. * * If the port is not currently shared via the tunnel, or if a host is not currently * connected to the tunnel, then requests to the port URI may result in a 502 Bad * Gateway response. */ public static URI getPortUri(TunnelEndpoint endpoint, int portNumber) { return TunnelEndpointStatics.getPortUri(endpoint, portNumber); } /** * Gets a ssh command which can be used to connect to a tunnel ssh port. * * SSH client on Windows/Linux/MacOS are supported. * * If the port is not currently shared via the tunnel, or if a host is not currently * connected to the tunnel, then ssh connection might fail. */ public static String getPortSshCommand(TunnelEndpoint endpoint, int portNumber) { return TunnelEndpointStatics.getPortSshCommand(endpoint, portNumber); } } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelEndpointStatics.java000066400000000000000000000020311450757157500330520ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.contracts; import java.net.URI; import org.apache.maven.shared.utils.StringUtils; class TunnelEndpointStatics { public static java.net.URI getPortUri(TunnelEndpoint endpoint, int portNumber) { if (portNumber == 0 && !StringUtils.isBlank(endpoint.tunnelUri)) { return URI.create(endpoint.tunnelUri); } if (StringUtils.isBlank(endpoint.portUriFormat)) { return null; } return URI.create(endpoint.portUriFormat.replace( TunnelEndpoint.portToken, Integer.toString(portNumber))); } public static String getPortSshCommand(TunnelEndpoint endpoint, int portNumber) { if (portNumber == 0 && !StringUtils.isBlank(endpoint.tunnelSshCommand)) { return endpoint.tunnelSshCommand; } if (StringUtils.isBlank(endpoint.portSshCommandFormat)) { return null; } return endpoint.portSshCommandFormat.replace( TunnelEndpoint.portToken, Integer.toString(portNumber)); } } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelHeaderNames.java000066400000000000000000000024601450757157500321210ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelHeaderNames.cs package com.microsoft.tunnels.contracts; /** * Header names for http requests that Tunnel Service can handle */ public class TunnelHeaderNames { /** * Additional authorization header that can be passed to tunnel web forwarding to * authenticate and authorize the client. The format of the value is the same as * Authorization header that is sent to the Tunnel service by the tunnel SDK. * Supported schemes: "tunnel" with the tunnel access JWT good for 'Connect' scope. */ public static final String xTunnelAuthorization = "X-Tunnel-Authorization"; /** * Request ID header that nginx ingress controller adds to all requests if it's not * there. */ public static final String xRequestID = "X-Request-ID"; /** * Github Ssh public key which can be used to validate if it belongs to tunnel's * owner. */ public static final String xGithubSshKey = "X-Github-Ssh-Key"; /** * Header that will skip the antiphishing page when connection to a tunnel through web * forwarding. */ public static final String xTunnelSkipAntiPhishingPage = "X-Tunnel-Skip-AntiPhishing-Page"; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelListByRegion.java000066400000000000000000000012721450757157500323170ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelListByRegion.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; /** * Tunnel list by region. */ public class TunnelListByRegion { /** * Azure region name. */ @Expose public String regionName; /** * Cluster id in the region. */ @Expose public String clusterId; /** * List of tunnels. */ @Expose public TunnelV2[] value; /** * Error detail if getting list of tunnels in the region failed. */ @Expose public ErrorDetail error; } TunnelListByRegionResponse.java000066400000000000000000000010521450757157500337530ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelListByRegionResponse.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; /** * Data contract for response of a list tunnel by region call. */ public class TunnelListByRegionResponse { /** * List of tunnels */ @Expose public TunnelListByRegion[] value; /** * Link to get next page of results. */ @Expose public String nextLink; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelListResponse.java000066400000000000000000000010051450757157500323710ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelListResponse.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; /** * Data contract for response of a list tunnel call. */ public class TunnelListResponse { /** * List of tunnels */ @Expose public TunnelV2[] value; /** * Link to get next page of results */ @Expose public String nextLink; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelOptions.java000066400000000000000000000055311450757157500314020ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelOptions.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; /** * Data contract for {@link Tunnel} or {@link TunnelPort} options. */ public class TunnelOptions { /** * Gets or sets a value indicating whether web-forwarding of this tunnel can run on * any cluster (region) without redirecting to the home cluster. This is only * applicable if the tunnel has a name and web-forwarding uses it. */ @Expose public boolean isGloballyAvailable; /** * Gets or sets a value for `Host` header rewriting to use in web-forwarding of this * tunnel or port. By default, with this property null or empty, web-forwarding uses * "localhost" to rewrite the header. Web-fowarding will use this property instead if * it is not null or empty. Port-level option, if set, takes precedence over this * option on the tunnel level. The option is ignored if IsHostHeaderUnchanged is true. */ @Expose public String hostHeader; /** * Gets or sets a value indicating whether `Host` header is rewritten or the header * value stays intact. By default, if false, web-forwarding rewrites the host header * with the value from HostHeader property or "localhost". If true, the host header * will be whatever the tunnel's web-forwarding host is, e.g. * tunnel-name-8080.devtunnels.ms. Port-level option, if set, takes precedence over * this option on the tunnel level. */ @Expose public boolean isHostHeaderUnchanged; /** * Gets or sets a value for `Origin` header rewriting to use in web-forwarding of this * tunnel or port. By default, with this property null or empty, web-forwarding uses * "http(s)://localhost" to rewrite the header. Web-fowarding will use this property * instead if it is not null or empty. Port-level option, if set, takes precedence * over this option on the tunnel level. The option is ignored if * IsOriginHeaderUnchanged is true. */ @Expose public String originHeader; /** * Gets or sets a value indicating whether `Origin` header is rewritten or the header * value stays intact. By default, if false, web-forwarding rewrites the origin header * with the value from OriginHeader property or "http(s)://localhost". If true, the * Origin header will be whatever the tunnel's web-forwarding Origin is, e.g. * https://tunnel-name-8080.devtunnels.ms. Port-level option, if set, takes precedence * over this option on the tunnel level. */ @Expose public boolean isOriginHeaderUnchanged; /** * Gets or sets if inspection is enabled for the tunnel. */ @Expose public boolean isInspectionEnabled; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelPort.java000066400000000000000000000065431450757157500306770ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelPort.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; import java.util.Map; /** * Data contract for tunnel port objects managed through the tunnel service REST API. */ public class TunnelPort { /** * Gets or sets the ID of the cluster the tunnel was created in. */ @Expose public String clusterId; /** * Gets or sets the generated ID of the tunnel, unique within the cluster. */ @Expose public String tunnelId; /** * Gets or sets the IP port number of the tunnel port. */ @Expose public int portNumber; /** * Gets or sets the optional short name of the port. * * The name must be unique among named ports of the same tunnel. */ @Expose public String name; /** * Gets or sets the optional description of the port. */ @Expose public String description; /** * Gets or sets the tags of the port. */ @Expose public String[] tags; /** * Gets or sets the protocol of the tunnel port. * * Should be one of the string constants from {@link TunnelProtocol}. */ @Expose public String protocol; /** * Gets or sets a value indicating whether this port is a default port for the tunnel. * * A client that connects to a tunnel (by ID or name) without specifying a port number * will connect to the default port for the tunnel, if a default is configured. Or if * the tunnel has only one port then the single port is the implicit default. * * Selection of a default port for a connection also depends on matching the * connection to the port {@link TunnelPort#protocol}, so it is possible to configure * separate defaults for distinct protocols like {@link TunnelProtocol#http} and * {@link TunnelProtocol#ssh}. */ @Expose public boolean isDefault; /** * Gets or sets a dictionary mapping from scopes to tunnel access tokens. * * Unlike the tokens in {@link Tunnel#accessTokens}, these tokens are restricted to * the individual port. */ @Expose public Map accessTokens; /** * Gets or sets access control settings for the tunnel port. * * See {@link TunnelAccessControl} documentation for details about the access control * model. */ @Expose public TunnelAccessControl accessControl; /** * Gets or sets options for the tunnel port. */ @Expose public TunnelOptions options; /** * Gets or sets current connection status of the tunnel port. */ @Expose public TunnelPortStatus status; /** * Gets or sets the username for the ssh service user is trying to forward. * * Should be provided if the {@link TunnelProtocol} is Ssh. */ @Expose public String sshUser; /** * Gets or sets web forwarding URIs. If set, it's a list of absolute URIs where the * port can be accessed with web forwarding. */ @Expose public String[] portForwardingUris; /** * Gets or sets inspection URI. If set, it's an absolute URIs where the port's traffic * can be inspected. */ @Expose public String inspectionUri; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelPortListResponse.java000066400000000000000000000010271450757157500332420ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelPortListResponse.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; /** * Data contract for response of a list tunnel ports call. */ public class TunnelPortListResponse { /** * List of tunnels */ @Expose public TunnelPortV2[] value; /** * Link to get next page of results */ @Expose public String nextLink; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelPortStatus.java000066400000000000000000000036421450757157500321000ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelPortStatus.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; import java.util.Date; /** * Data contract for {@link TunnelPort} status. */ public class TunnelPortStatus { /** * Gets or sets the current value and limit for the number of clients connected to the * port. * * This client connection count does not include non-port-specific connections such as * SDK and SSH clients. See {@link TunnelStatus#clientConnectionCount} for status of * those connections. This count also does not include HTTP client connections, * unless they are upgraded to websockets. HTTP connections are counted per-request * rather than per-connection: see {@link TunnelPortStatus#httpRequestRate}. */ @Expose public ResourceStatus clientConnectionCount; /** * Gets or sets the UTC date time when a client was last connected to the port, or * null if a client has never connected. */ @Expose public Date lastClientConnectionTime; /** * Gets or sets the current value and limit for the rate of client connections to the * tunnel port. * * This client connection rate does not count non-port-specific connections such as * SDK and SSH clients. See {@link TunnelStatus#clientConnectionRate} for those * connection types. This also does not include HTTP connections, unless they are * upgraded to websockets. HTTP connections are counted per-request rather than * per-connection: see {@link TunnelPortStatus#httpRequestRate}. */ @Expose public RateStatus clientConnectionRate; /** * Gets or sets the current value and limit for the rate of HTTP requests to the * tunnel port. */ @Expose public RateStatus httpRequestRate; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelPortV2.java000066400000000000000000000065531450757157500311100ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelPortV2.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; import java.util.Map; /** * Data contract for tunnel port objects managed through the tunnel service REST API. */ public class TunnelPortV2 { /** * Gets or sets the ID of the cluster the tunnel was created in. */ @Expose public String clusterId; /** * Gets or sets the generated ID of the tunnel, unique within the cluster. */ @Expose public String tunnelId; /** * Gets or sets the IP port number of the tunnel port. */ @Expose public int portNumber; /** * Gets or sets the optional short name of the port. * * The name must be unique among named ports of the same tunnel. */ @Expose public String name; /** * Gets or sets the optional description of the port. */ @Expose public String description; /** * Gets or sets the tags of the port. */ @Expose public String[] labels; /** * Gets or sets the protocol of the tunnel port. * * Should be one of the string constants from {@link TunnelProtocol}. */ @Expose public String protocol; /** * Gets or sets a value indicating whether this port is a default port for the tunnel. * * A client that connects to a tunnel (by ID or name) without specifying a port number * will connect to the default port for the tunnel, if a default is configured. Or if * the tunnel has only one port then the single port is the implicit default. * * Selection of a default port for a connection also depends on matching the * connection to the port {@link TunnelPortV2#protocol}, so it is possible to * configure separate defaults for distinct protocols like {@link TunnelProtocol#http} * and {@link TunnelProtocol#ssh}. */ @Expose public boolean isDefault; /** * Gets or sets a dictionary mapping from scopes to tunnel access tokens. * * Unlike the tokens in {@link Tunnel#accessTokens}, these tokens are restricted to * the individual port. */ @Expose public Map accessTokens; /** * Gets or sets access control settings for the tunnel port. * * See {@link TunnelAccessControl} documentation for details about the access control * model. */ @Expose public TunnelAccessControl accessControl; /** * Gets or sets options for the tunnel port. */ @Expose public TunnelOptions options; /** * Gets or sets current connection status of the tunnel port. */ @Expose public TunnelPortStatus status; /** * Gets or sets the username for the ssh service user is trying to forward. * * Should be provided if the {@link TunnelProtocol} is Ssh. */ @Expose public String sshUser; /** * Gets or sets web forwarding URIs. If set, it's a list of absolute URIs where the * port can be accessed with web forwarding. */ @Expose public String[] portForwardingUris; /** * Gets or sets inspection URI. If set, it's an absolute URIs where the port's traffic * can be inspected. */ @Expose public String inspectionUri; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelProtocol.java000066400000000000000000000017211450757157500315450ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelProtocol.cs package com.microsoft.tunnels.contracts; /** * Defines possible values for the protocol of a {@link TunnelPort}. */ public class TunnelProtocol { /** * The protocol is automatically detected. (TODO: Define detection semantics.) */ public static final String auto = "auto"; /** * Unknown TCP protocol. */ public static final String tcp = "tcp"; /** * Unknown UDP protocol. */ public static final String udp = "udp"; /** * SSH protocol. */ public static final String ssh = "ssh"; /** * Remote desktop protocol. */ public static final String rdp = "rdp"; /** * HTTP protocol. */ public static final String http = "http"; /** * HTTPS protocol. */ public static final String https = "https"; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelRelayTunnelEndpoint.java000066400000000000000000000011401450757157500337020ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelRelayTunnelEndpoint.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; /** * Parameters for connecting to a tunnel via the tunnel service's built-in relay function. */ public class TunnelRelayTunnelEndpoint extends TunnelEndpoint { /** * Gets or sets the host URI. */ @Expose public String hostRelayUri; /** * Gets or sets the client URI. */ @Expose public String clientRelayUri; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelServiceProperties.java000066400000000000000000000114531450757157500334240ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelServiceProperties.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; /** * Provides environment-dependent properties about the service. */ public class TunnelServiceProperties { TunnelServiceProperties (String serviceUri, String serviceAppId, String serviceInternalAppId, String gitHubAppClientId) { this.serviceUri = serviceUri; this.serviceAppId = serviceAppId; this.serviceInternalAppId = serviceInternalAppId; this.gitHubAppClientId = gitHubAppClientId; } /** * Global DNS name of the production tunnel service. */ static final String prodDnsName = "global.rel.tunnels.api.visualstudio.com"; /** * Global DNS name of the pre-production tunnel service. */ static final String ppeDnsName = "global.rel.tunnels.ppe.api.visualstudio.com"; /** * Global DNS name of the development tunnel service. */ static final String devDnsName = "global.ci.tunnels.dev.api.visualstudio.com"; /** * First-party app ID: `Visual Studio Tunnel Service` * * Used for authenticating AAD/MSA users, and service principals outside the AME * tenant, in the PROD service environment. */ static final String prodFirstPartyAppId = "46da2f7e-b5ef-422a-88d4-2a7f9de6a0b2"; /** * First-party app ID: `Visual Studio Tunnel Service - Test` * * Used for authenticating AAD/MSA users, and service principals outside the AME * tenant, in the PPE and DEV service environments. */ static final String nonProdFirstPartyAppId = "54c45752-bacd-424a-b928-652f3eca2b18"; /** * Third-party app ID: `tunnels-prod-app-sp` * * Used for authenticating internal AAD service principals in the AME tenant, in the * PROD service environment. */ static final String prodThirdPartyAppId = "ce65d243-a913-4cae-a7dd-cb52e9f77647"; /** * Third-party app ID: `tunnels-ppe-app-sp` * * Used for authenticating internal AAD service principals in the AME tenant, in the * PPE service environment. */ static final String ppeThirdPartyAppId = "544167a6-f431-4518-aac6-2fd50071928e"; /** * Third-party app ID: `tunnels-dev-app-sp` * * Used for authenticating internal AAD service principals in the corp tenant (not * AME!), in the DEV service environment. */ static final String devThirdPartyAppId = "a118c979-0249-44bb-8f95-eb0457127aeb"; /** * GitHub App Client ID for 'Visual Studio Tunnel Service' * * Used by client apps that authenticate tunnel users with GitHub, in the PROD service * environment. */ static final String prodGitHubAppClientId = "Iv1.e7b89e013f801f03"; /** * GitHub App Client ID for 'Visual Studio Tunnel Service - Test' * * Used by client apps that authenticate tunnel users with GitHub, in the PPE and DEV * service environments. */ static final String nonProdGitHubAppClientId = "Iv1.b231c327f1eaa229"; /** * Gets production service properties. */ public static final TunnelServiceProperties production = TunnelServicePropertiesStatics.production; /** * Gets properties for the service in the staging environment (PPE). */ public static final TunnelServiceProperties staging = TunnelServicePropertiesStatics.staging; /** * Gets properties for the service in the development environment. */ public static final TunnelServiceProperties development = TunnelServicePropertiesStatics.development; /** * Gets the base URI of the service. */ @Expose public final String serviceUri; /** * Gets the public AAD AppId for the service. * * Clients specify this AppId as the audience property when authenticating to the * service. */ @Expose public final String serviceAppId; /** * Gets the internal AAD AppId for the service. * * Other internal services specify this AppId as the audience property when * authenticating to the tunnel service. Production services must be in the AME tenant * to use this appid. */ @Expose public final String serviceInternalAppId; /** * Gets the client ID for the service's GitHub app. * * Clients apps that authenticate tunnel users with GitHub specify this as the client * ID when requesting a user token. */ @Expose public final String gitHubAppClientId; /** * Gets properties for the service in the specified environment. */ public static TunnelServiceProperties environment(String environmentName) { return TunnelServicePropertiesStatics.environment(environmentName); } } TunnelServicePropertiesStatics.java000066400000000000000000000037541450757157500347050ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.contracts; import java.util.Locale; import org.apache.maven.shared.utils.StringUtils; class TunnelServicePropertiesStatics { /** * Gets production service properties. */ static final TunnelServiceProperties production = new TunnelServiceProperties( "https://" + TunnelServiceProperties.prodDnsName + "/", TunnelServiceProperties.prodFirstPartyAppId, TunnelServiceProperties.prodThirdPartyAppId, TunnelServiceProperties.prodGitHubAppClientId); /** * Gets properties for the service in the staging environment (PPE). */ static final TunnelServiceProperties staging = new TunnelServiceProperties( "https://" + TunnelServiceProperties.ppeDnsName + "/", TunnelServiceProperties.nonProdFirstPartyAppId, TunnelServiceProperties.ppeThirdPartyAppId, TunnelServiceProperties.nonProdGitHubAppClientId); /** * Gets properties for the service in the development environment. */ static final TunnelServiceProperties development = new TunnelServiceProperties( "https://" + TunnelServiceProperties.devDnsName + "/", TunnelServiceProperties.nonProdFirstPartyAppId, TunnelServiceProperties.devThirdPartyAppId, TunnelServiceProperties.nonProdGitHubAppClientId); public static TunnelServiceProperties environment(String environmentName) { if (StringUtils.isBlank(environmentName)) { throw new IllegalArgumentException(environmentName); } switch (environmentName.toLowerCase(Locale.ROOT)) { case "prod": case "production": return TunnelServiceProperties.production; case "ppe": case "preprod": case "staging": return TunnelServiceProperties.staging; case "dev": case "development": return TunnelServiceProperties.development; default: throw new IllegalArgumentException("Invalid service environment: " + environmentName); } } } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelStatus.java000066400000000000000000000111751450757157500312330ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelStatus.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; import java.util.Date; /** * Data contract for {@link Tunnel} status. */ public class TunnelStatus { /** * Gets or sets the current value and limit for the number of ports on the tunnel. */ @Expose public ResourceStatus portCount; /** * Gets or sets the current value and limit for the number of hosts currently * accepting connections to the tunnel. * * This is typically 0 or 1, but may be more than 1 if the tunnel options allow * multiple hosts. */ @Expose public ResourceStatus hostConnectionCount; /** * Gets or sets the UTC time when a host was last accepting connections to the tunnel, * or null if a host has never connected. */ @Expose public Date lastHostConnectionTime; /** * Gets or sets the current value and limit for the number of clients connected to the * tunnel. * * This counts non-port-specific client connections, which is SDK and SSH clients. See * {@link TunnelPortStatus} for status of per-port client connections. */ @Expose public ResourceStatus clientConnectionCount; /** * Gets or sets the UTC time when a client last connected to the tunnel, or null if a * client has never connected. * * This reports times for non-port-specific client connections, which is SDK client * and SSH clients. See {@link TunnelPortStatus} for per-port client connections. */ @Expose public Date lastClientConnectionTime; /** * Gets or sets the current value and limit for the rate of client connections to the * tunnel. * * This counts non-port-specific client connections, which is SDK client and SSH * clients. See {@link TunnelPortStatus} for status of per-port client connections. */ @Expose public RateStatus clientConnectionRate; /** * Gets or sets the current value and limit for the rate of bytes being received by * the tunnel host and uploaded by tunnel clients. * * All types of tunnel and port connections, from potentially multiple clients, can * contribute to this rate. The reported rate may differ slightly from the rate * measurable by applications, due to protocol overhead. Data rate status reporting is * delayed by a few seconds, so this value is a snapshot of the data transfer rate * from a few seconds earlier. */ @Expose public RateStatus uploadRate; /** * Gets or sets the current value and limit for the rate of bytes being sent by the * tunnel host and downloaded by tunnel clients. * * All types of tunnel and port connections, from potentially multiple clients, can * contribute to this rate. The reported rate may differ slightly from the rate * measurable by applications, due to protocol overhead. Data rate status reporting is * delayed by a few seconds, so this value is a snapshot of the data transfer rate * from a few seconds earlier. */ @Expose public RateStatus downloadRate; /** * Gets or sets the total number of bytes received by the tunnel host and uploaded by * tunnel clients, over the lifetime of the tunnel. * * All types of tunnel and port connections, from potentially multiple clients, can * contribute to this total. The reported value may differ slightly from the value * measurable by applications, due to protocol overhead. Data transfer status * reporting is delayed by a few seconds. */ @Expose public long uploadTotal; /** * Gets or sets the total number of bytes sent by the tunnel host and downloaded by * tunnel clients, over the lifetime of the tunnel. * * All types of tunnel and port connections, from potentially multiple clients, can * contribute to this total. The reported value may differ slightly from the value * measurable by applications, due to protocol overhead. Data transfer status * reporting is delayed by a few seconds. */ @Expose public long downloadTotal; /** * Gets or sets the current value and limit for the rate of management API read * operations for the tunnel or tunnel ports. */ @Expose public RateStatus apiReadRate; /** * Gets or sets the current value and limit for the rate of management API update * operations for the tunnel or tunnel ports. */ @Expose public RateStatus apiUpdateRate; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/contracts/TunnelV2.java000066400000000000000000000057031450757157500302370ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../../../../../../cs/src/Contracts/TunnelV2.cs package com.microsoft.tunnels.contracts; import com.google.gson.annotations.Expose; import java.util.Date; import java.util.Map; /** * Data contract for tunnel objects managed through the tunnel service REST API. */ public class TunnelV2 { /** * Gets or sets the ID of the cluster the tunnel was created in. */ @Expose public String clusterId; /** * Gets or sets the generated ID of the tunnel, unique within the cluster. */ @Expose public String tunnelId; /** * Gets or sets the optional short name (alias) of the tunnel. * * The name must be globally unique within the parent domain, and must be a valid * subdomain. */ @Expose public String name; /** * Gets or sets the description of the tunnel. */ @Expose public String description; /** * Gets or sets the tags of the tunnel. */ @Expose public String[] labels; /** * Gets or sets the optional parent domain of the tunnel, if it is not using the * default parent domain. */ @Expose public String domain; /** * Gets or sets a dictionary mapping from scopes to tunnel access tokens. */ @Expose public Map accessTokens; /** * Gets or sets access control settings for the tunnel. * * See {@link TunnelAccessControl} documentation for details about the access control * model. */ @Expose public TunnelAccessControl accessControl; /** * Gets or sets default options for the tunnel. */ @Expose public TunnelOptions options; /** * Gets or sets current connection status of the tunnel. */ @Expose public TunnelStatus status; /** * Gets or sets an array of endpoints where hosts are currently accepting client * connections to the tunnel. */ @Expose public TunnelEndpoint[] endpoints; /** * Gets or sets a list of ports in the tunnel. * * This optional property enables getting info about all ports in a tunnel at the same * time as getting tunnel info, or creating one or more ports at the same time as * creating a tunnel. It is omitted when listing (multiple) tunnels, or when updating * tunnel properties. (For the latter, use APIs to create/update/delete individual * ports instead.) */ @Expose public TunnelPortV2[] ports; /** * Gets or sets the time in UTC of tunnel creation. */ @Expose public Date created; /** * Gets or the time the tunnel will be deleted if it is not used or updated. */ @Expose public Date expiration; /** * Gets or the custom amount of time the tunnel will be valid if it is not used or * updated in seconds. */ @Expose public int customExpiration; } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/management/000077500000000000000000000000001450757157500260265ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/management/HttpMethod.java000066400000000000000000000006011450757157500307460ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.management; /** * HttpMethod. */ public enum HttpMethod { GET("GET"), POST("POST"), PUT("PUT"), DELETE("DELETE"); private final String stringValue; HttpMethod(final String s) { stringValue = s; } public String toString() { return stringValue; } } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/management/HttpResponseException.java000066400000000000000000000015661450757157500332160ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.management; /** * Runtime Exception thrown on failed http requests. Contains the response * statusCode. */ public class HttpResponseException extends RuntimeException { public int statusCode; public String responseBody; public HttpResponseException(String message, int statusCode) { super(message); this.statusCode = statusCode; } public HttpResponseException(String message, int statusCode, Throwable cause) { super(message, cause); this.statusCode = statusCode; } /** * Exception thrown for http response with status code > 300. */ public HttpResponseException( String message, int statusCode, String responseBody) { super(message); this.statusCode = statusCode; this.responseBody = responseBody; } } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/management/ITunnelManagementClient.java000066400000000000000000000204371450757157500334110ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.management; import com.microsoft.tunnels.contracts.ClusterDetails; import com.microsoft.tunnels.contracts.NamedRateStatus; import com.microsoft.tunnels.contracts.Tunnel; import com.microsoft.tunnels.contracts.TunnelConnectionMode; import com.microsoft.tunnels.contracts.TunnelEndpoint; import com.microsoft.tunnels.contracts.TunnelPort; import java.util.Collection; import java.util.concurrent.CompletableFuture; /** * Interface for a client that manages tunnels and tunnel ports * via the tunnel service management API. */ public interface ITunnelManagementClient { /** * Lists tunnels that are owned by the caller. * * @param clusterId A tunnel cluster ID, or null to list tunnels globally. * @param domain Tunnel domain, or null for the default domain. * @param options Request options. * @return Array of tunnel objects. */ public CompletableFuture> listTunnelsAsync( String clusterId, String domain, TunnelRequestOptions options); /** * Gets one tunnel by ID or name. * * @param tunnel Tunnel object including at least either a tunnel name * (globally unique, if configured) or tunnel ID and cluster ID. * @param options Request options. * @return The requested tunnel object, or null if the ID or name was not found. */ public CompletableFuture getTunnelAsync(Tunnel tunnel, TunnelRequestOptions options); /** * Creates a tunnel. * * @param tunnel Tunnel object including all required properties. * @param options Request options. * @return The created tunnel object. */ public CompletableFuture createTunnelAsync(Tunnel tunnel, TunnelRequestOptions options); /** * Updates properties of a tunnel. * * @param tunnel Tunnel object including at least either a tunnel name * (globally unique, if configured) or tunnel ID and cluster ID. * Any non-null properties on the object will be updated; * null properties will not be modified. * @param options Request options. * @return Updated tunnel object, including both updated and unmodified * properties. */ public CompletableFuture updateTunnelAsync(Tunnel tunnel, TunnelRequestOptions options); /** * Deletes a tunnel. * * @param tunnel Tunnel object including at least either a tunnel name * (globally unique, if configured) or tunnel ID and cluster ID. * @param options Request options. * @return True if the tunnel was deleted; false if it was not found. */ public CompletableFuture deleteTunnelAsync(Tunnel tunnel, TunnelRequestOptions options); /** * Creates or updates an endpoint for the tunnel. * *

* Note: A tunnel endpoint specifies how and where hosts and clients can connect * to a * tunnel. * Hosts create one or more endpoints when they start accepting connections on a * tunnel, * and delete the endpoints when they stop accepting connections. *

* * @param tunnel Tunnel object including at least either a tunnel name * (globally unique, if configured) or tunnel ID and cluster ID. * @param endpoint Endpoint object to add or update, including at least * connection mode and host ID properties. * @param options Request options. * @return The created or updated tunnel endpoint, with any server-supplied * properties filled. */ public CompletableFuture updateTunnelEndpointAsync( Tunnel tunnel, TunnelEndpoint endpoint, TunnelRequestOptions options); /** * Deletes a tunnel endpoint. * *

* Hosts create one or more endpoints when they start accepting connections on a * tunnel, * and delete the endpoints when they stop accepting connections. *

* * @param tunnel Tunnel object including at least either a tunnel * name * (globally unique, if configured) or tunnel ID and * cluster ID. * @param hostId Required ID of the host for endpoint(s) to be * deleted. * @param tunnelConnectionMode Optional connection mode for endpoint(s) to be * deleted, * or null to delete endpoints for all connection * modes. * @param options Request options. * @return True if one or more endpoints were deleted, false if none were found. */ public CompletableFuture deleteTunnelEndpointsAsync( Tunnel tunnel, String hostId, TunnelConnectionMode tunnelConnectionMode, TunnelRequestOptions options); /** * Lists all ports on a tunnel. * * @param tunnel Tunnel object including at least either a tunnel name * (globally unique, if configured) or tunnel ID and cluster ID. * @param options Request options. * @return Array of tunnel port objects. */ public CompletableFuture> listTunnelPortsAsync( Tunnel tunnel, TunnelRequestOptions options); /** * Gets one port on a tunnel by port number. * * @param tunnel Tunnel object including at least either a tunnel name * (globally unique, if configured) or tunnel ID and cluster * ID. * @param portNumber Port number. * @param options Request options. * @return The requested tunnel port object, or null if the port number * was not found. */ public CompletableFuture getTunnelPortAsync( Tunnel tunnel, int portNumber, TunnelRequestOptions options); /** * Creates a tunnel port. * * @param tunnel Tunnel object including at least either a tunnel name * (globally unique, if configured) or tunnel ID and cluster * ID. * @param tunnelPort Tunnel port object including all required properties. * @param options Request options. * @return The created tunnel port object. */ public CompletableFuture createTunnelPortAsync( Tunnel tunnel, TunnelPort tunnelPort, TunnelRequestOptions options); /** * Updates properties of a tunnel port. * * @param tunnel Tunnel object including at least either a tunnel name * (globally unique, if configured) or tunnel ID and cluster * ID. * @param tunnelPort Tunnel port object including at least a port number. * Any additional non-null properties on the object will be * updated; null properties * will not be modified. * @param options Request options. * @return Updated tunnel port object, including both updated and unmodified * properties. */ public CompletableFuture updateTunnelPortAsync( Tunnel tunnel, TunnelPort tunnelPort, TunnelRequestOptions options); /** * Deletes a tunnel port. * * @param tunnel Tunnel object including at least either a tunnel name * (globally unique, if configured) or tunnel ID and cluster * ID. * @param portNumber Port number of the port to delete. * @param options Request options. * @return True if the tunnel port was deleted; false if it was not found. */ public CompletableFuture deleteTunnelPortAsync( Tunnel tunnel, int portNumber, TunnelRequestOptions options); /** * Checks if a tunnel name is available * * @param name Name to check. * @return True if the name is available; false if it is taken. */ public CompletableFuture checkNameAvailabilityAsync( String name); /** * Lists details of tunneling service clusters in all supported Azure regions. * @return Array of {@link ClusterDetails}. */ public CompletableFuture> listClustersAsync(); /** * Lists limits and consumption status for the calling user. * @return Array of {@link NamedRateStatus}. */ public CompletableFuture> listUserLimitsAsync(); } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/management/ProductHeaderValue.java000066400000000000000000000010071450757157500324150ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.management; /** * Represents the User-Agent header value in the form of productName/version. */ public class ProductHeaderValue { public String productName; public String version; public ProductHeaderValue(String productName) { this(productName, "unknown"); } public ProductHeaderValue(String productName, String version) { this.productName = productName; this.version = version; } } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/management/TunnelManagementClient.java000066400000000000000000000643571450757157500333110ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.management; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; import com.microsoft.tunnels.contracts.ClusterDetails; import com.microsoft.tunnels.contracts.NamedRateStatus; import com.microsoft.tunnels.contracts.Tunnel; import com.microsoft.tunnels.contracts.TunnelAccessControlEntry; import com.microsoft.tunnels.contracts.TunnelAccessScopes; import com.microsoft.tunnels.contracts.TunnelConnectionMode; import com.microsoft.tunnels.contracts.TunnelContracts; import com.microsoft.tunnels.contracts.TunnelEndpoint; import com.microsoft.tunnels.contracts.TunnelPort; import java.io.UnsupportedEncodingException; import java.lang.reflect.Type; import java.net.URI; import java.net.URISyntaxException; import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpRequest.BodyPublishers; import java.net.http.HttpResponse; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; import java.util.stream.Collectors; import org.apache.maven.shared.utils.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Implementation of a client that manages tunnels and tunnel ports via the * tunnel service management API. */ public class TunnelManagementClient implements ITunnelManagementClient { private static final String SDK_USER_AGENT = "tunnels-java-sdk/" + TunnelManagementClient.class.getPackage().getImplementationVersion(); private static final String AUTH_HEADER = "Authorization"; private static final String CONTENT_TYPE_HEADER = "Content-Type"; private static final String USER_AGENT_HEADER = "User-Agent"; // Api strings private static final String prodServiceUri = "https://global.rel.tunnels.api.visualstudio.com"; private static final String apiV1Path = "/api/v1"; private static final String tunnelsApiPath = apiV1Path + "/tunnels"; private static final String userLimitsApiPath = apiV1Path + "/userlimits"; private static final String subjectsApiPath = apiV1Path + "/subjects"; private static final String endpointsApiSubPath = "/endpoints"; private static final String portsApiSubPath = "/ports"; private String clustersApiPath = apiV1Path + "/clusters"; private static final String tunnelAuthenticationScheme = "Tunnel"; private static final String checkTunnelNamePath = "/checkNameAvailability"; // Access Scopes private static final String[] ManageAccessTokenScope = { TunnelAccessScopes.manage }; private static final String[] HostAccessTokenScope = { TunnelAccessScopes.host }; private static final String[] ManagePortsAccessTokenScopes = { TunnelAccessScopes.manage, TunnelAccessScopes.managePorts, TunnelAccessScopes.host, }; private static final String[] ReadAccessTokenScopes = { TunnelAccessScopes.manage, TunnelAccessScopes.managePorts, TunnelAccessScopes.host, TunnelAccessScopes.connect }; private static final Logger logger = LoggerFactory.getLogger(TunnelManagementClient.class); private final HttpClient httpClient; private final ProductHeaderValue[] userAgents; private final Supplier> userTokenCallback; private final String baseAddress; public TunnelManagementClient(ProductHeaderValue[] userAgents) { this(userAgents, null, null); } public TunnelManagementClient( ProductHeaderValue[] userAgents, Supplier> userTokenCallback) { this(userAgents, userTokenCallback, null); } /** * Initiates a new instance of the TunnelManagementClient class. * * @param userAgents List of User-Agent headers given as a * {@link ProductHeaderValue}. * @param userTokenCallback A callback which should resolve to the * Authentication header value. * @param tunnelServiceUri Uri for the tunnel service. Defaults to the * production service url. */ public TunnelManagementClient( ProductHeaderValue[] userAgents, Supplier> userTokenCallback, String tunnelServiceUri) { if (userAgents.length == 0) { throw new IllegalArgumentException("user agents cannot be empty"); } this.userAgents = userAgents; this.userTokenCallback = userTokenCallback != null ? userTokenCallback : () -> CompletableFuture.completedFuture(null); this.baseAddress = tunnelServiceUri != null ? tunnelServiceUri : prodServiceUri; this.httpClient = HttpClient.newHttpClient(); } private CompletableFuture requestAsync( Tunnel tunnel, TunnelRequestOptions options, HttpMethod requestMethod, URI uri, String[] scopes, T requestObject, Type responseType) { return createHttpRequest(tunnel, options, requestMethod, uri, requestObject, scopes) .thenCompose(request -> { long startTime = System.nanoTime(); return this.httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) .thenApply(response -> { long stopTime = System.nanoTime(); long durationMs = (stopTime - startTime) / 1000000; var statusCode = response.statusCode(); var message = requestMethod + " " + uri + " -> " + statusCode + " (" + durationMs + " ms)"; if (statusCode >= 200 && statusCode < 300) { logger.info(message); } else { logger.warn(message); } return response; }); }) .thenApply(response -> parseResponse(response, responseType)); } private CompletableFuture getAuthHeaderValue( Tunnel tunnel, TunnelRequestOptions options, String[] accessTokenScopes) { if (options != null && StringUtils.isNotBlank(options.accessToken)) { logger.debug("Authenticating the request with an access token from request options."); return CompletableFuture.completedFuture( tunnelAuthenticationScheme + " " + options.accessToken); } return this.userTokenCallback.get().thenApply(userAuthHeader -> { if (StringUtils.isNotBlank(userAuthHeader)) { logger.debug("Authenticating the request via the user token callback."); return userAuthHeader; } if (tunnel != null && tunnel.accessTokens != null) { for (String scope : accessTokenScopes) { String accessToken = null; for (Map.Entry scopeAndToken : tunnel.accessTokens.entrySet()) { // Each key may be either a single scope or space-delimited list of scopes. if (scopeAndToken.getKey().contains(" ")) { var scopes = scopeAndToken.getKey().split(" "); if (Arrays.asList(scopes).contains(scope)) { accessToken = scopeAndToken.getValue(); break; } } else { accessToken = scopeAndToken.getValue(); break; } } if (StringUtils.isNotBlank(accessToken)) { logger.debug( "Authenticating the request with a '" + scope + "' token from the tunnel object."); return tunnelAuthenticationScheme + " " + accessToken; } } } logger.debug("The request will be unauthenticated. No authentication token is available."); return null; }); } private CompletableFuture createHttpRequest( Tunnel tunnel, TunnelRequestOptions options, HttpMethod requestMethod, URI uri, T requestObject, String[] accessTokenScopes) { return getAuthHeaderValue(tunnel, options, accessTokenScopes).thenApply(authHeaderValue -> { String userAgentString = ""; for (ProductHeaderValue userAgent : this.userAgents) { userAgentString = userAgent.productName + "/" + userAgent.version + " " + userAgentString; } userAgentString = userAgentString + SDK_USER_AGENT; var requestBuilder = HttpRequest.newBuilder() .uri(uri) .header(USER_AGENT_HEADER, userAgentString) .header(CONTENT_TYPE_HEADER, "application/json"); if (StringUtils.isNotBlank(authHeaderValue)) { requestBuilder.header(AUTH_HEADER, authHeaderValue); } if (options != null && options.additionalHeaders != null) { options.additionalHeaders.forEach( (key, value) -> requestBuilder.header(key, value) ); } Gson gson = TunnelContracts.getGson(); var requestJson = gson.toJson(requestObject); var bodyPublisher = requestMethod == HttpMethod.POST || requestMethod == HttpMethod.PUT ? BodyPublishers.ofString(requestJson) : BodyPublishers.noBody(); requestBuilder.method(requestMethod.toString(), bodyPublisher); return requestBuilder.build(); }); } private T parseResponse(HttpResponse response, Type typeOfT) { if (response.statusCode() >= 300) { throw new HttpResponseException( "Error sending request, status code: " + response.statusCode(), response.statusCode(), response.body()); } Gson gson = TunnelContracts.getGson(); return gson.fromJson(response.body(), typeOfT); } private URI buildUri(Tunnel tunnel, TunnelRequestOptions options) { return buildUri(tunnel, options, null, null); } private URI buildUri(Tunnel tunnel, TunnelRequestOptions options, String path) { return buildUri(tunnel, options, path, null); } private URI buildUri(Tunnel tunnel, TunnelRequestOptions options, String path, String query) { if (tunnel == null) { throw new Error("Tunnel must be specified"); } String tunnelPath; if (StringUtils.isNotBlank(tunnel.clusterId) && StringUtils.isNotBlank(tunnel.tunnelId)) { tunnelPath = tunnelsApiPath + "/" + tunnel.tunnelId; } else { if (tunnel.name == null) { throw new Error("Tunnel object must include either a name or tunnel ID and cluster ID."); } if (StringUtils.isBlank(tunnel.domain)) { tunnelPath = tunnelsApiPath + "/" + tunnel.name; } else { tunnelPath = tunnelsApiPath + "/" + tunnel.name + "." + tunnel.domain; } } if (StringUtils.isNotBlank(path)) { tunnelPath += path; } return buildUri(tunnel.clusterId, tunnelPath, options, query); } private URI buildUri(String clusterId, String path, TunnelRequestOptions options, String query) { URI baseAddress; try { baseAddress = new URI(this.baseAddress); } catch (URISyntaxException e) { throw new Error("Error parsing URI: " + this.baseAddress); } String host = null; int port = baseAddress.getPort(); if (StringUtils.isNotBlank(clusterId)) { if (!baseAddress.getHost().equals("localhost") && !baseAddress.getHost().startsWith(clusterId + ".")) { host = (clusterId + "." + baseAddress.getHost()).replace("global.", ""); } else if (baseAddress.getScheme().equals("https") && clusterId.startsWith("localhost") && baseAddress.getPort() % 10 > 0) { var clusterNumber = Integer.parseInt(clusterId.substring("localhost".length())); if (clusterNumber > 0 && clusterNumber < 10) { port = baseAddress.getPort() - (baseAddress.getPort() % 10) + clusterNumber; } } } String queryString = ""; if (options != null) { queryString = options.toQueryString(); } if (query != null) { queryString += StringUtils.isBlank(queryString) ? query : "&" + query; } try { return new URI( baseAddress.getScheme(), null /* userInfo */, host != null ? host : baseAddress.getHost(), port, path, StringUtils.isNotBlank(queryString) ? queryString : null, null /* fragment */ ); } catch (URISyntaxException e) { throw new Error("Error building URI: " + e.getMessage()); } } @Override public CompletableFuture> listTunnelsAsync( String clusterId, String domain, TunnelRequestOptions options) { var query = StringUtils.isBlank(clusterId) ? "global=true" : null; var requestUri = this.buildUri(clusterId, tunnelsApiPath, options, query); final Type responseType = new TypeToken>() { }.getType(); return requestAsync( null /* tunnel */, options, HttpMethod.GET, requestUri, ReadAccessTokenScopes, null /* requestObject */, responseType); } @Override public CompletableFuture getTunnelAsync(Tunnel tunnel, TunnelRequestOptions options) { var requestUri = buildUri(tunnel, options, null, null); final Type responseType = new TypeToken() { }.getType(); return requestAsync( tunnel, options, HttpMethod.GET, requestUri, ReadAccessTokenScopes, null, responseType); } @Override public CompletableFuture createTunnelAsync(Tunnel tunnel, TunnelRequestOptions options) { if (tunnel.tunnelId != null) { throw new IllegalArgumentException("Tunnel ID may not be specified when creating a tunnel."); } var uri = buildUri(tunnel.clusterId, tunnelsApiPath, options, null); final Type responseType = new TypeToken() { }.getType(); return requestAsync( tunnel, options, HttpMethod.POST, uri, ManageAccessTokenScope, convertTunnelForRequest(tunnel), responseType); } private Tunnel convertTunnelForRequest(Tunnel tunnel) { Tunnel converted = new Tunnel(); converted.name = tunnel.name; converted.domain = tunnel.domain; converted.description = tunnel.description; converted.tags = tunnel.tags; converted.options = tunnel.options; converted.accessControl = tunnel.accessControl; converted.customExpiration = tunnel.customExpiration; converted.endpoints = tunnel.endpoints; if (tunnel.accessControl != null && tunnel.accessControl.entries != null) { List entries = Arrays.asList(tunnel.accessControl.entries); List filtered = entries.stream() .filter((e) -> !e.isInherited) .collect(Collectors.toList()); converted.accessControl.entries = filtered.toArray(new TunnelAccessControlEntry[0]); } if (tunnel.ports == null) { converted.ports = null; } else { var convertedPorts = new TunnelPort[tunnel.ports.length]; for (int i = 0; i < tunnel.ports.length; i++) { convertedPorts[i] = convertTunnelPortForRequest(tunnel, tunnel.ports[i]); } converted.ports = convertedPorts; } return converted; } @Override public CompletableFuture updateTunnelAsync(Tunnel tunnel, TunnelRequestOptions options) { var uri = buildUri(tunnel, options); final Type responseType = new TypeToken() { }.getType(); return requestAsync( tunnel, options, HttpMethod.PUT, uri, ManageAccessTokenScope, convertTunnelForRequest(tunnel), responseType); } public CompletableFuture deleteTunnelAsync(Tunnel tunnel) { return deleteTunnelAsync(tunnel, null); } @Override public CompletableFuture deleteTunnelAsync(Tunnel tunnel, TunnelRequestOptions options) { var uri = buildUri(tunnel, options); final Type responseType = new TypeToken() { }.getType(); return requestAsync( tunnel, options, HttpMethod.DELETE, uri, ManageAccessTokenScope, convertTunnelForRequest(tunnel), responseType); } @Override public CompletableFuture updateTunnelEndpointAsync( Tunnel tunnel, TunnelEndpoint endpoint, TunnelRequestOptions options) { if (endpoint == null) { throw new IllegalArgumentException("Endpoint must not be null."); } if (StringUtils.isBlank(endpoint.hostId)) { throw new IllegalArgumentException("Endpoint hostId must not be null."); } var path = endpointsApiSubPath + "/" + endpoint.hostId + "/" + endpoint.connectionMode; var uri = buildUri( tunnel, options, path); final Type responseType = new TypeToken() { }.getType(); CompletableFuture result = requestAsync( tunnel, options, HttpMethod.PUT, uri, HostAccessTokenScope, endpoint, responseType); if (tunnel.endpoints != null) { var updatedEndpoints = new ArrayList(); for (TunnelEndpoint e : tunnel.endpoints) { if (e.hostId != endpoint.hostId || e.connectionMode != endpoint.connectionMode) { updatedEndpoints.add(e); } } updatedEndpoints.add(result.join()); tunnel.endpoints = updatedEndpoints .toArray(new TunnelEndpoint[updatedEndpoints.size()]); } return result; } @Override public CompletableFuture deleteTunnelEndpointsAsync( Tunnel tunnel, String hostId, TunnelConnectionMode connectionMode, TunnelRequestOptions options) { if (hostId == null) { throw new IllegalArgumentException("hostId must not be null"); } var path = endpointsApiSubPath + "/" + hostId; if (connectionMode != null) { path += "/" + connectionMode; } var uri = buildUri(tunnel, options, path); final Type responseType = new TypeToken() { }.getType(); CompletableFuture result = requestAsync( tunnel, options, HttpMethod.DELETE, uri, ManageAccessTokenScope, convertTunnelForRequest(tunnel), responseType); if (tunnel.endpoints != null) { var updatedEndpoints = new ArrayList(); for (TunnelEndpoint e : tunnel.endpoints) { if (e.hostId != hostId || e.connectionMode != connectionMode) { updatedEndpoints.add(e); } } tunnel.endpoints = updatedEndpoints .toArray(new TunnelEndpoint[updatedEndpoints.size()]); } return result; } @Override public CompletableFuture> listTunnelPortsAsync( Tunnel tunnel, TunnelRequestOptions options) { var uri = buildUri(tunnel, options, portsApiSubPath); final Type responseType = new TypeToken>() { }.getType(); return requestAsync( tunnel, options, HttpMethod.GET, uri, ReadAccessTokenScopes, null /* requestObject */, responseType); } @Override public CompletableFuture getTunnelPortAsync( Tunnel tunnel, int portNumber, TunnelRequestOptions options) { var uri = buildUri(tunnel, options, portsApiSubPath + "/" + portNumber); final Type responseType = new TypeToken() { }.getType(); return requestAsync( tunnel, options, HttpMethod.GET, uri, ReadAccessTokenScopes, null, responseType); } @Override public CompletableFuture createTunnelPortAsync( Tunnel tunnel, TunnelPort tunnelPort, TunnelRequestOptions options) { if (tunnel == null) { throw new IllegalArgumentException("Tunnel must not be null."); } if (tunnelPort == null) { throw new IllegalArgumentException("Tunnel port must be specified"); } var uri = buildUri(tunnel, options, portsApiSubPath); final Type responseType = new TypeToken() { }.getType(); CompletableFuture result = requestAsync( tunnel, options, HttpMethod.POST, uri, ManagePortsAccessTokenScopes, convertTunnelPortForRequest(tunnel, tunnelPort), responseType); if (tunnel.ports != null) { var updatedPorts = new ArrayList(); for (TunnelPort p : tunnel.ports) { if (p.portNumber != tunnelPort.portNumber) { updatedPorts.add(p); } } updatedPorts.add(result.join()); updatedPorts.sort((p1, p2) -> Integer.compare(p1.portNumber, p2.portNumber)); tunnel.ports = updatedPorts.toArray(new TunnelPort[updatedPorts.size()]); } return result; } private TunnelPort convertTunnelPortForRequest(Tunnel tunnel, TunnelPort tunnelPort) { if (tunnelPort.clusterId != null && tunnel.clusterId != null && !tunnelPort.clusterId.equals(tunnel.clusterId)) { throw new IllegalArgumentException( "Tunnel port cluster ID does not match tunnel."); } if (tunnelPort.tunnelId != null && tunnel.tunnelId != null && !tunnelPort.tunnelId.equals(tunnel.tunnelId)) { throw new IllegalArgumentException( "Tunnel port tunnel ID does not match tunnel."); } var converted = new TunnelPort(); converted.portNumber = tunnelPort.portNumber; converted.protocol = tunnelPort.protocol; converted.isDefault = tunnelPort.isDefault; converted.description = tunnelPort.description; converted.tags = tunnelPort.tags; converted.sshUser = tunnelPort.sshUser; converted.options = tunnelPort.options; if (tunnelPort.accessControl != null && tunnelPort.accessControl.entries != null) { List entries = Arrays.asList(tunnel.accessControl.entries); List filtered = entries.stream() .filter((e) -> !e.isInherited) .collect(Collectors.toList()); converted.accessControl = tunnelPort.accessControl; converted.accessControl.entries = filtered.toArray(new TunnelAccessControlEntry[0]); } return converted; } @Override public CompletableFuture updateTunnelPortAsync( Tunnel tunnel, TunnelPort tunnelPort, TunnelRequestOptions options) { if (tunnel == null) { throw new IllegalArgumentException("Tunnel must not be null."); } if (tunnelPort == null) { throw new IllegalArgumentException("Tunnel port must not be null."); } if (StringUtils.isNotBlank(tunnelPort.clusterId) && StringUtils.isNotBlank(tunnel.clusterId) && tunnelPort.clusterId != tunnel.clusterId) { throw new Error("Tunnel port cluster ID is not consistent."); } var path = portsApiSubPath + "/" + tunnelPort.portNumber; var uri = buildUri( tunnel, options, path); final Type responseType = new TypeToken() { }.getType(); CompletableFuture result = requestAsync( tunnel, options, HttpMethod.PUT, uri, ManagePortsAccessTokenScopes, convertTunnelPortForRequest(tunnel, tunnelPort), responseType); if (tunnel.ports != null) { var updatedPorts = new ArrayList(); for (TunnelPort p : tunnel.ports) { if (p.portNumber != tunnelPort.portNumber) { updatedPorts.add(p); } } updatedPorts.add(result.join()); updatedPorts.sort((p1, p2) -> Integer.compare(p1.portNumber, p2.portNumber)); tunnel.ports = updatedPorts.toArray(new TunnelPort[updatedPorts.size()]); } return result; } @Override public CompletableFuture deleteTunnelPortAsync( Tunnel tunnel, int portNumber, TunnelRequestOptions options) { if (tunnel == null) { throw new IllegalArgumentException("Tunnel must not be null."); } var path = portsApiSubPath + "/" + portNumber; var uri = buildUri(tunnel, options, path); final Type responseType = new TypeToken() { }.getType(); CompletableFuture result = requestAsync( tunnel, options, HttpMethod.DELETE, uri, ManagePortsAccessTokenScopes, null /* requestObject */, responseType); if (tunnel.ports != null) { var updatedPorts = new ArrayList(); for (TunnelPort p : tunnel.ports) { if (p.portNumber != portNumber) { updatedPorts.add(p); } } updatedPorts.sort((p1, p2) -> Integer.compare(p1.portNumber, p2.portNumber)); tunnel.ports = updatedPorts.toArray(new TunnelPort[updatedPorts.size()]); } return result; } /** * {@inheritDoc} */ public CompletableFuture> listClustersAsync() { URI uri; try { uri = new URI(this.baseAddress + this.clustersApiPath); final Type responseType = new TypeToken>() { }.getType(); return requestAsync( null, null, HttpMethod.GET, uri, null, null, responseType); } catch (URISyntaxException e) { throw new Error("Error parsing URI: " + this.baseAddress + this.clustersApiPath); } } /** * {@inheritDoc} */ public CompletableFuture checkNameAvailabilityAsync(String name) { URI uri; try { uri = new URI(this.baseAddress + this.tunnelsApiPath + name + this.checkTunnelNamePath); final Type responseType = new TypeToken() { }.getType(); name = URLEncoder.encode(name, "UTF-8"); return requestAsync( null, null, HttpMethod.GET, uri, null, null, responseType); } catch (URISyntaxException e) { throw new Error("Error parsing URI: " + this.baseAddress + this.tunnelsApiPath + name + this.checkTunnelNamePath); } catch (UnsupportedEncodingException e) { throw new Error("Error encoding tunnel name: " + name); } } @Override public CompletableFuture> listUserLimitsAsync() { URI uri; try { uri = new URI(this.baseAddress + TunnelManagementClient.userLimitsApiPath); final Type responseType = new TypeToken>() {}.getType(); return requestAsync( null, null, HttpMethod.GET, uri, null, null, responseType); } catch (URISyntaxException e) { throw new Error("Error parsing URI: " + this.baseAddress + TunnelManagementClient.userLimitsApiPath); } } } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/management/TunnelRequestOptions.java000066400000000000000000000132471450757157500330720ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.management; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.stream.Collectors; import java.util.stream.Stream; import com.microsoft.tunnels.contracts.TunnelAccessControl; /** * TunnelRequestOptions. */ public class TunnelRequestOptions { /** *

* Gets or sets an access token for the request. *

* *

* Note this should not be a _user_ access token (such as AAD or GitHub); use the * callback parameter to the `TunnelManagementClient` constructor to * supply user access tokens. *

*/ public String accessToken; /** * Gets or sets additional headers to be included in the request. */ public HashMap additionalHeaders; /** * Gets or sets additional query parameters to be included in the request. */ public HashMap additionalQueryParameters; /** *

* Gets or sets a value that indicates whether HTTP redirect responses will be * automatically followed. *

* *

* The default is true. If false, a redirect response will cause an error to be * thrown, * with redirect target URI available at `error.response.headers.location`. *

* *

* The tunnel service often redirects requests to the "home" cluster of the * requested * tunnel, when necessary to fulfill the request. *

*/ public boolean followRedirects; /** * Gets or sets a flag that requests tunnel ports when retrieving a tunnel object. * * Ports are excluded by default when retrieving a tunnel or when listing or searching * tunnels. This option enables including ports for all tunnels returned by a list or * search query. */ public boolean includePorts; /** * Gets or sets a flag that requests access control details when retrieving tunnels. * * Access control details are always included when retrieving a single tunnel, * but excluded by default when listing or searching tunnels. This option enables * including access controls for all tunnels returned by a list or search query. */ public boolean includeAccessControl; /** * Gets or sets an optional list of tags to filter the requested tunnels or ports. * * Requested tags are compared to the `Tunnel.tags` or `TunnelPort.tags` when calling * `TunnelManagementClient.listTunnels` or `TunnelManagementClient.listTunnelPorts` respectively. * By default, an item is included if ANY tag matches; set `requireAllTags` to match * ALL tags instead. */ public Collection tags; /* * Gets or sets a flag that indicates whether listed items must match all tags * specified in `tags`. If false, an item is included if any tag matches. */ public boolean requireAllTags; /** * Gets or sets an optional list of token scopes that are requested when retrieving a * tunnel or tunnel port object. * * Each item in the list must be a single scope from `TunnelAccessScopes` or a space- * delimited combination of multiple scopes. The service issues an access token for * each scope or combination and returns the token(s) in the `Tunnel.accessTokens` or * `TunnelPort.accessTokens` dictionary. If the caller does not have permission to get * a token for one or more scopes then a token is not returned but the overall request * does not fail. Token properties including scopes and expiration may be checked using * `TunnelAccessTokenProperties`. */ public Collection tokenScopes; /** * If true on a create or update request then upon a name conflict, attempt to rename the * existing tunnel to null and give the name to the tunnel from the request. */ public boolean forceRename; /** * Limits the number of tunnels returned when searching or listing tunnels. */ public Integer limit; /** * Converts tunnel request options to a query string for HTTP requests to the * tunnel management API. */ public String toQueryString() { var queryOptions = new HashMap>(); if (this.includePorts) { queryOptions.put("includePorts", Arrays.asList("true")); } if (this.includeAccessControl) { queryOptions.put("includeAccessControl", Arrays.asList("true")); } if (this.tokenScopes != null) { TunnelAccessControl.validateScopes(this.tokenScopes, null, true); queryOptions.put("tokenScopes", this.tokenScopes); } if (this.forceRename) { queryOptions.put("forceRename", Arrays.asList("true")); } if (this.tags != null) { queryOptions.put("tags", this.tags); if (this.requireAllTags) { queryOptions.put("allTags", Arrays.asList("true")); } } if (this.limit != null) { queryOptions.put("limit", Arrays.asList(this.limit.toString())); } if (this.additionalQueryParameters != null) { this.additionalQueryParameters.forEach( (key, value) -> queryOptions.put(key, Arrays.asList(value)) ); } Stream encodedParameters = queryOptions.entrySet().stream().map(o -> { Stream encodedValues = o.getValue().stream().map(v -> { final String encoding = "UTF-8"; try { return URLEncoder.encode(v, encoding); } catch (UnsupportedEncodingException e) { throw new IllegalArgumentException("Bad encoding: " + encoding); } }); return o.getKey() + "=" + encodedValues.collect(Collectors.joining("&")); }); return encodedParameters.collect(Collectors.joining("&")); } } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/websocket/000077500000000000000000000000001450757157500257005ustar00rootroot00000000000000WebSocketConnectionHandler.java000066400000000000000000000067271450757157500337040ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/websocket// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.websocket; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInboundHandlerAdapter; import io.netty.channel.ChannelPromise; import io.netty.handler.codec.http.DefaultHttpHeaders; import io.netty.handler.codec.http.FullHttpResponse; import io.netty.handler.codec.http.HttpHeaders; import io.netty.handler.codec.http.HttpMessage; import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; import io.netty.handler.codec.http.websocketx.PongWebSocketFrame; import io.netty.handler.codec.http.websocketx.WebSocketClientHandshaker; import io.netty.handler.codec.http.websocketx.WebSocketClientHandshakerFactory; import io.netty.handler.codec.http.websocketx.WebSocketHandshakeException; import io.netty.handler.codec.http.websocketx.WebSocketVersion; import java.net.URI; public class WebSocketConnectionHandler extends ChannelInboundHandlerAdapter { private final String subprotocol = "tunnel-relay-client"; private final WebSocketClientHandshaker handshaker; private ChannelPromise handshakeFuture; private WebSocketSession session; /** * Handles the initial websocket upgrade and converts subsequent websocket * frames to byte buffers for the ssh session. */ public WebSocketConnectionHandler( WebSocketSession session, URI webSocketUri, String accessToken) { super(); HttpHeaders headers = new DefaultHttpHeaders(); headers.set("Authorization", "tunnel " + accessToken); this.handshaker = WebSocketClientHandshakerFactory.newHandshaker( webSocketUri, WebSocketVersion.V13, subprotocol, true, headers); this.session = session; } /** * Calling channelActive on the session itself is deferred until the websocket * handshake is complete in the initial channelRead */ @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { handshaker.handshake(ctx.channel()); } @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { session.channelInactive(ctx); } @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { if (msg instanceof HttpMessage) { if (!handshaker.isHandshakeComplete()) { try { handshaker.finishHandshake(ctx.channel(), (FullHttpResponse) msg); handshakeFuture.setSuccess(); session.channelActive(ctx); } catch (WebSocketHandshakeException e) { handshakeFuture.setFailure(e); } return; } else { throw new Error("Unexpected HTTP Message: " + msg.toString()); } } else if (msg instanceof BinaryWebSocketFrame) { BinaryWebSocketFrame frame = (BinaryWebSocketFrame) msg; session.channelRead(ctx, frame.content()); return; } else if (msg instanceof PongWebSocketFrame) { // ignore keep alive message response. return; } else { throw new Error("Unexpected message: " + msg.toString() + " of type: " + msg.getClass().getName()); } } public ChannelFuture handshakeFuture() { return handshakeFuture; } @Override public void handlerAdded(ChannelHandlerContext ctx) { handshakeFuture = ctx.newPromise(); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { session.exceptionCaught(ctx, cause); } } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/websocket/WebSocketConnector.java000066400000000000000000000114111450757157500323020ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.websocket; import io.netty.channel.Channel; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelPipeline; import io.netty.channel.socket.SocketChannel; import io.netty.handler.codec.http.HttpClientCodec; import io.netty.handler.codec.http.HttpObjectAggregator; import io.netty.handler.codec.http.websocketx.extensions.compression.WebSocketClientCompressionHandler; import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslContextBuilder; import io.netty.handler.ssl.SslHandler; import io.netty.handler.ssl.util.InsecureTrustManagerFactory; import java.net.SocketAddress; import java.net.URI; import org.apache.sshd.common.AttributeRepository; import org.apache.sshd.common.io.IoConnectFuture; import org.apache.sshd.common.io.IoHandler; import org.apache.sshd.common.io.IoServiceEventListener; import org.apache.sshd.netty.NettyIoConnector; public class WebSocketConnector extends NettyIoConnector { protected final URI webSocketUri; protected final String accessToken; protected WebSocketSession webSocketSession; /** * Modifies the NettyIoConnector to create a websocket session. */ public WebSocketConnector(WebSocketServiceFactory factory, IoHandler handler) { super(factory, handler); this.webSocketUri = factory.webSocketUri; this.accessToken = factory.accessToken; webSocketSession = new WebSocketSession( WebSocketConnector.this, handler, null, /* acceptanceAddress */ factory.webSocketUri, factory.accessToken); bootstrap.handler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) throws Exception { IoServiceEventListener listener = getIoServiceEventListener(); SocketAddress local = ch.localAddress(); SocketAddress remote = ch.remoteAddress(); AttributeRepository context = ch.hasAttr(CONTEXT_KEY) ? ch.attr(CONTEXT_KEY).get() : null; try { if (listener != null) { try { listener.connectionEstablished(WebSocketConnector.this, local, context, remote); } catch (Exception e) { ch.close(); throw e; } } if (context != null) { webSocketSession.setAttribute(AttributeRepository.class, context); } ChannelPipeline p = ch.pipeline(); if (factory.webSocketUri.getScheme().equals("wss")) { SslContext sslContext = SslContextBuilder.forClient() .trustManager(InsecureTrustManagerFactory.INSTANCE).build(); p.addLast("ssl", new SslHandler(sslContext.newEngine(ch.alloc()))); } p.addLast(new HttpClientCodec()); p.addLast(new HttpObjectAggregator(8192)); p.addLast(WebSocketClientCompressionHandler.INSTANCE); p.addLast(webSocketSession.webSocketConnectionHandler); } catch (Exception e) { if (listener != null) { try { listener.abortEstablishedConnection( WebSocketConnector.this, local, context, remote, e); } catch (Exception exc) { if (log.isDebugEnabled()) { log.debug("initChannel(" + ch + ") listener=" + listener + " ignoring abort event exception", exc); } } } throw e; } } }); } @Override public IoConnectFuture connect( SocketAddress address, AttributeRepository context, SocketAddress localAddress) { boolean debugEnabled = log.isDebugEnabled(); if (debugEnabled) { log.debug("Connecting to {}", address); } IoConnectFuture future = new DefaultIoConnectFuture(address, null); var relayPort = webSocketUri.getPort(); if (relayPort == -1) { if (webSocketUri.getScheme().equals("wss")) { relayPort = 443; } else if (webSocketUri.getScheme().equals("ws")) { relayPort = 80; } else { throw new IllegalStateException("Unexpected tunnel relay client uri scheme: " + webSocketUri); } } ChannelFuture chf = bootstrap.connect(webSocketUri.getHost(), relayPort); Channel channel = chf.channel(); channel.attr(CONNECT_FUTURE_KEY).set(future); if (context != null) { channel.attr(CONTEXT_KEY).set(context); } chf.addListener(cf -> { Throwable t = chf.cause(); if (t != null) { future.setException(t); } else if (chf.isCancelled()) { future.cancel(); } }); return future; } } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/websocket/WebSocketServiceFactory.java000066400000000000000000000022761450757157500333110ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.websocket; import io.netty.channel.EventLoopGroup; import java.net.URI; import org.apache.sshd.common.io.IoAcceptor; import org.apache.sshd.common.io.IoConnector; import org.apache.sshd.common.io.IoHandler; import org.apache.sshd.netty.NettyIoAcceptor; import org.apache.sshd.netty.NettyIoServiceFactory; public class WebSocketServiceFactory extends NettyIoServiceFactory { protected final URI webSocketUri; protected final String accessToken; public WebSocketServiceFactory() { this(null, null, null); } /** * Creates the websocket connector and acceptor. */ public WebSocketServiceFactory(EventLoopGroup group, URI webSocketUri, String accessToken) { super(group); this.webSocketUri = webSocketUri; this.accessToken = accessToken; } public EventLoopGroup getEventLoopGroup() { return this.eventLoopGroup; } @Override public IoConnector createConnector(IoHandler handler) { return new WebSocketConnector(this, handler); } @Override public IoAcceptor createAcceptor(IoHandler handler) { return new NettyIoAcceptor(this, handler); } } WebSocketServiceFactoryFactory.java000066400000000000000000000026661450757157500345650ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/websocket// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.websocket; import io.netty.channel.EventLoopGroup; import java.net.URI; import java.util.Objects; import org.apache.sshd.common.FactoryManager; import org.apache.sshd.common.io.AbstractIoServiceFactoryFactory; import org.apache.sshd.common.io.IoServiceFactory; public class WebSocketServiceFactoryFactory extends AbstractIoServiceFactoryFactory { protected final EventLoopGroup eventLoopGroup; protected final URI webSocketUri; protected final String accessToken; public WebSocketServiceFactoryFactory() { this(null, null, null); } public WebSocketServiceFactoryFactory(URI webSocketUri, String accessToken) { this(null, webSocketUri, accessToken); } /** * Creates the WebSocketServiceFactory. */ public WebSocketServiceFactoryFactory( EventLoopGroup eventLoopGroup, URI webSocketUri, String accessToken) { super(null); this.eventLoopGroup = eventLoopGroup; this.webSocketUri = webSocketUri; this.accessToken = accessToken; } @Override public IoServiceFactory create(FactoryManager manager) { Objects.requireNonNull(manager, "No factory manager provided"); IoServiceFactory factory = new WebSocketServiceFactory( eventLoopGroup, webSocketUri, accessToken); factory.setIoServiceEventListener(manager.getIoServiceEventListener()); return factory; } } dev-tunnels-0.0.25/java/src/main/java/com/microsoft/tunnels/websocket/WebSocketSession.java000066400000000000000000000053501450757157500320000ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels.websocket; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelPromise; import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; import java.net.SocketAddress; import java.net.URI; import java.util.concurrent.*; import org.apache.sshd.common.io.IoHandler; import org.apache.sshd.common.io.IoWriteFuture; import org.apache.sshd.common.util.buffer.Buffer; import org.apache.sshd.netty.NettyIoService; import org.apache.sshd.netty.NettyIoSession; public class WebSocketSession extends NettyIoSession { protected WebSocketConnectionHandler webSocketConnectionHandler; private Semaphore reading; /** * Creates a modified Netty session to handle websocket messages. */ public WebSocketSession( NettyIoService service, IoHandler handler, SocketAddress acceptanceAddress, URI webSocketUri, String accessToken) { super(service, handler, acceptanceAddress); this.webSocketConnectionHandler = new WebSocketConnectionHandler( this, webSocketUri, accessToken); this.reading = new Semaphore(1); } @Override public IoWriteFuture writeBuffer(Buffer buffer) { int bufLen = buffer.available(); ByteBuf buf = Unpooled.buffer(bufLen); buf.writeBytes(buffer.array(), buffer.rpos(), bufLen); DefaultIoWriteFuture msg = new DefaultIoWriteFuture(getRemoteAddress(), null); ChannelPromise next = context.newPromise(); prev.addListener((unused) -> { if (context != null) { context.writeAndFlush(new BinaryWebSocketFrame(buf), next); } }); prev = next; next.addListener(fut -> { if (fut.isSuccess()) { msg.setValue(Boolean.TRUE); } else { msg.setValue(fut.cause()); } }); return msg; } @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { super.channelActive(ctx); } @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { super.channelInactive(ctx); } @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { this.reading.acquire(); this.reading.release(); super.channelRead(ctx, msg); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { super.exceptionCaught(ctx, cause); } @Override public void suspendRead() { try { this.reading.tryAcquire(1, 1, TimeUnit.SECONDS); } catch (InterruptedException e) { return; } } @Override public void resumeRead() { this.reading.release(); } } dev-tunnels-0.0.25/java/src/test/000077500000000000000000000000001450757157500165515ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/test/java/000077500000000000000000000000001450757157500174725ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/test/java/com/000077500000000000000000000000001450757157500202505ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/test/java/com/microsoft/000077500000000000000000000000001450757157500222555ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/test/java/com/microsoft/tunnels/000077500000000000000000000000001450757157500237455ustar00rootroot00000000000000dev-tunnels-0.0.25/java/src/test/java/com/microsoft/tunnels/TunnelClientTests.java000066400000000000000000000107531450757157500302450ustar00rootroot00000000000000package com.microsoft.tunnels; import com.microsoft.tunnels.connections.ForwardedPort; import com.microsoft.tunnels.connections.ForwardedPortEventListener; import com.microsoft.tunnels.connections.TunnelClient; import com.microsoft.tunnels.connections.TunnelRelayTunnelClient; import com.microsoft.tunnels.contracts.Tunnel; import com.microsoft.tunnels.contracts.TunnelPort; import com.microsoft.tunnels.management.HttpResponseException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import org.junit.Assert; import org.junit.BeforeClass; import org.junit.Test; public class TunnelClientTests extends TunnelTest { private static Tunnel testTunnel; @BeforeClass public static void initializeTestTunnel() { testTunnel = getTestTunnel(); } @Test public void connectClient() { TunnelClient client = new TunnelRelayTunnelClient(); // connect to the tunnel client.connectAsync(testTunnel).thenRun(() -> { logger.info("TunnelRelayTunnel client connected successfully."); client.stop(); }).join(); } @Test public void addAndRemovePorts() { var testPort = new TunnelPort(); testPort.portNumber = 5000; testPort.protocol = "https"; TunnelRelayTunnelClient client = new TunnelRelayTunnelClient(); client.getForwardedPorts().addListener(new ForwardedPortEventListener() { @Override public void onForwardedPortAdded(ForwardedPort port) { logger.info("Port added - local: " + port.getLocalPort() + " remote: " + port.getRemotePort()); Assert.assertEquals(testPort.portNumber, port.getRemotePort()); } @Override public void onForwardedPortRemoved(ForwardedPort port) { logger.info("Port removed - local: " + port.getLocalPort() + " remote: " + port.getRemotePort()); Assert.assertEquals(testPort.portNumber, port.getRemotePort()); } }); // Ensure the port was deleted on previous test. try { tunnelManagementClient.deleteTunnelPortAsync(testTunnel, testPort.portNumber, null).join(); } catch (CompletionException e) { var cause = e.getCause(); if (cause instanceof HttpResponseException && ((HttpResponseException) cause).statusCode != 404) { throw e; } } client.connectAsync(testTunnel).join(); // Ensure that the test port is not being forwarded. logForwardedPorts(client); Assert.assertEquals("Expected no ports to be forwarded yet", 0, client.getForwardedPorts().size()); // Add a port using the management client logger.info("Adding port " + testPort.portNumber + " to test tunnel " + testTunnelName); tunnelManagementClient.createTunnelPortAsync(testTunnel, testPort, null).join(); logForwardedPorts(client); // Verify that the local port is not updated without calling refreshPorts Assert.assertEquals("Expected forwarded ports to not be updated.", 0, client.getForwardedPorts().size()); logger.info("Refreshing ports of test tunnel " + testTunnelName); // Call refresh ports and verify that the port is updated. client.refreshPortsAsync().join(); logForwardedPorts(client); Assert.assertEquals("Expected port " + testPort.portNumber + " to be added to the forwarded ports collection.", 1, client.getForwardedPorts().size()); // Calling refresh with no new ports added should do nothing. client.refreshPortsAsync().join(); Assert.assertEquals("Expected port " + testPort.portNumber + " to be added to the forwarded ports collection.", 1, client.getForwardedPorts().size()); // Delete the port and verify that the correct port is removed from the // collection. logger.info("Deleting port " + testPort.portNumber + " of test tunnel " + testTunnelName); tunnelManagementClient.deleteTunnelPortAsync(testTunnel, testPort.portNumber, null).join(); client.refreshPortsAsync().join(); logForwardedPorts(client); Assert.assertEquals("Expected port " + testPort.portNumber + " to be removed from the forwarded ports collection.", 0, client.getForwardedPorts().size()); client.stop(); } private void logForwardedPorts(TunnelRelayTunnelClient tunnelClient) { var ports = tunnelClient.getForwardedPorts(); String message = "Forwarded ports: ["; for (ForwardedPort port : ports) { message += "{ local: " + port.getLocalPort() + ", remote: " + port.getRemotePort() + " },"; } message += "]"; logger.info(message); } } dev-tunnels-0.0.25/java/src/test/java/com/microsoft/tunnels/TunnelContractsTests.java000066400000000000000000000102611450757157500307610ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import com.microsoft.tunnels.contracts.ResourceStatus; import com.microsoft.tunnels.contracts.TunnelAccessControl; import com.microsoft.tunnels.contracts.TunnelConstraints; import com.microsoft.tunnels.contracts.TunnelContracts; import com.microsoft.tunnels.contracts.TunnelEndpoint; import com.microsoft.tunnels.contracts.TunnelServiceProperties; import java.net.URI; import java.util.Arrays; import org.junit.Test; /** * Unit test for the static contracts classes. * * Tests should call from the generated contracts (insread of from the * *Statics.java) classes * to ensure the contracts have been correctly generated and linked to the * handwritten files. */ public class TunnelContractsTests { @Test public void tunnelConstraints() { assertTrue(TunnelConstraints.isValidClusterId("usw2")); assertFalse(TunnelConstraints.isValidClusterId("usw$2")); // unallowed special character assertTrue(TunnelConstraints.isValidOldTunnelId("bcd123fg")); assertFalse(TunnelConstraints.isValidOldTunnelId("bcd123fgh")); // id too long assertTrue(TunnelConstraints.isValidTunnelName("mytunnel")); assertFalse(TunnelConstraints.isValidTunnelName("my$unnel")); // unallowed special character assertTrue(TunnelConstraints.isValidTunnelIdOrName("mytunnel")); assertTrue(TunnelConstraints.isValidTunnelIdOrName("bcd123fg")); assertFalse(TunnelConstraints.isValidTunnelIdOrName("my$unnel")); assertTrue(TunnelConstraints.isValidTag("my-tunnel-tag")); assertFalse(TunnelConstraints.isValidTag("my-tunnel&tag")); // unallowed specialcharacter assertNotNull(TunnelConstraints.validateOldTunnelId("bcdf123g", null)); assertNotNull(TunnelConstraints.validateTunnelIdOrName("mytunnel", null)); } @Test public void tunnelProperties() { var prod = TunnelServiceProperties.environment("prod"); var dev = TunnelServiceProperties.environment("dev"); var ppe = TunnelServiceProperties.environment("ppe"); assertTrue(prod != null && prod instanceof TunnelServiceProperties); assertTrue(dev != null && dev instanceof TunnelServiceProperties); assertTrue(ppe != null && ppe instanceof TunnelServiceProperties); } @Test public void tunnelEndpoint() { TunnelEndpoint endpoint = new TunnelEndpoint(); endpoint.portUriFormat = "{port}.test.com"; URI uri = TunnelEndpoint.getPortUri(endpoint, 3000); assertNotNull(uri); assertTrue(uri.toString().equals("3000.test.com")); } @Test public void tunnelAccessControl() { var scopes = Arrays.asList("connect", "host"); var validScopes = Arrays.asList("connect", "create", "inspect", "host", "manage"); var invalidScopes = Arrays.asList("connect", "invalid"); var multiScopes = Arrays.asList("host connect", "manage"); TunnelAccessControl.validateScopes(scopes, null, false); TunnelAccessControl.validateScopes(validScopes, null, false); TunnelAccessControl.validateScopes(scopes, validScopes, false); var exception = assertThrows(IllegalArgumentException.class, ()-> { TunnelAccessControl.validateScopes(invalidScopes, validScopes, false); }); assertTrue(exception.getMessage().equals("Invalid tunnel access scope: invalid")); TunnelAccessControl.validateScopes(multiScopes, null, true); exception = assertThrows(IllegalArgumentException.class, ()-> { TunnelAccessControl.validateScopes(multiScopes, null, false); }); assertTrue(exception.getMessage().equals("Invalid tunnel access scope: host connect")); } @Test public void resourceStatus() { var gson = TunnelContracts.getGson(); var result1 = gson.fromJson("{\"current\": 3, \"limit\": 10 }", ResourceStatus.class); assertNotEquals(0, result1.current); assertNotEquals(0, result1.limit); var result2 = gson.fromJson("3", ResourceStatus.class); assertEquals(result1.current, result2.current); } } dev-tunnels-0.0.25/java/src/test/java/com/microsoft/tunnels/TunnelManagementClientTests.java000066400000000000000000000133561450757157500322440ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import com.microsoft.tunnels.contracts.Tunnel; import com.microsoft.tunnels.contracts.TunnelAccessControl; import com.microsoft.tunnels.contracts.TunnelAccessControlEntry; import com.microsoft.tunnels.contracts.TunnelAccessControlEntryType; import com.microsoft.tunnels.contracts.TunnelAccessScopes; import com.microsoft.tunnels.contracts.TunnelPort; import com.microsoft.tunnels.contracts.TunnelProtocol; import com.microsoft.tunnels.management.HttpResponseException; import com.microsoft.tunnels.management.TunnelRequestOptions; import java.util.Arrays; import java.util.concurrent.CompletableFuture; import org.junit.Test; /** * TunnelManagementClient tests. */ public class TunnelManagementClientTests extends TunnelTest { @Test public void createTunnel() { // Set up tunnel access control. var tunnelAccessEntry = new TunnelAccessControlEntry(); tunnelAccessEntry.type = TunnelAccessControlEntryType.Anonymous; tunnelAccessEntry.subjects = new String[] {}; tunnelAccessEntry.scopes = new String[] { "connect" }; var access = new TunnelAccessControl(); access.entries = new TunnelAccessControlEntry[] { tunnelAccessEntry }; // set up the tunnel port. var port = new TunnelPort(); port.portNumber = 3000; port.protocol = TunnelProtocol.https; // Set up tunnel. Tunnel tunnel = new Tunnel(); tunnel.accessControl = access; tunnel.ports = new TunnelPort[] { port }; // Configure tunnel request options. var requestOptions = new TunnelRequestOptions(); requestOptions.tokenScopes = Arrays.asList(TunnelAccessScopes.host); requestOptions.includePorts = true; var createdTunnel = tryCreateTunnel(tunnel, requestOptions); assertNotNull(createdTunnel.clusterId); assertNotNull(createdTunnel.accessTokens); assertNotNull(createdTunnel.accessControl); assertNotNull(createdTunnel.created); assertNotNull(createdTunnel.description); assertNull(createdTunnel.domain); assertNotNull(createdTunnel.endpoints); assertNotNull(createdTunnel.name); assertNotNull(createdTunnel.options); assertNotNull(createdTunnel.ports); assertNotNull(createdTunnel.status); assertNotNull(createdTunnel.tags); assertNotNull(createdTunnel.tunnelId); tunnelManagementClient.deleteTunnelAsync(createdTunnel).join(); } @Test public void getTunnel() { Tunnel tunnel = new Tunnel(); var options = new TunnelRequestOptions(); var createdTunnel = tryCreateTunnel(tunnel, options); var result = tunnelManagementClient.getTunnelAsync(createdTunnel, options).join(); assertEquals( "Incorrect tunnel ID. Actual: " + result.tunnelId + " Expected: " + createdTunnel.tunnelId, result.tunnelId, createdTunnel.tunnelId); assertNotNull("Tunnel ID should not be null", result.tunnelId); tunnelManagementClient.deleteTunnelAsync(createdTunnel, options).join(); } @Test public void updateTunnelAsync() { Tunnel tunnel = new Tunnel(); var options = new TunnelRequestOptions(); var createdTunnel = tryCreateTunnel(tunnel, options); assertEquals("Created tunnel description should be blank.", createdTunnel.description, ""); var description = "updated"; createdTunnel.description = description; var updatedTunnel = tunnelManagementClient.updateTunnelAsync(createdTunnel, options).join(); assertEquals("Tunnel description should have been updated.", updatedTunnel.description, description); tunnelManagementClient.deleteTunnelAsync(createdTunnel, options).join(); } @Test public void createTunnelPort() { var port = new TunnelPort(); var portNumber = 3000; port.portNumber = portNumber; port.protocol = TunnelProtocol.https; var tunnel = new Tunnel(); var options = new TunnelRequestOptions(); var createdTunnel = tryCreateTunnel(tunnel, options); assertNull("Tunnel should have been created with no ports.", createdTunnel.ports); var result = tunnelManagementClient.createTunnelPortAsync(createdTunnel, port, options).join(); // Expect properties specified at creation to be equal. assertEquals("Expected ports to be equal.", portNumber, result.portNumber); assertEquals("Expected protocol to be equal.", TunnelProtocol.https, result.protocol); // Expect unspecified properties to have been initialized to defaults. assertNull(result.accessTokens); assertNotNull(result.clusterId); assertNotNull(result.options); assertNotNull(result.status); assertNotNull(result.tunnelId); } /** * Attempts to create the test tunnel. If the tunnel already exists, it will * delete and re-create it. * * @param tunnel {@link Tunnel} * @param options {@link TunnelRequestOptions} * @return The created Tunnel */ private Tunnel tryCreateTunnel(Tunnel tunnel, TunnelRequestOptions options) { CompletableFuture result = tunnelManagementClient.createTunnelAsync(tunnel, options) .exceptionally(err -> { // if test tunnel already exists, delete and resend create request. if (err.getCause() instanceof HttpResponseException && ((HttpResponseException) err.getCause()).statusCode == 409) { tunnelManagementClient.deleteTunnelAsync(tunnel, options).join(); return tunnelManagementClient.createTunnelAsync(tunnel, options).join(); } throw new Error(err.getCause()); }); Tunnel createdTunnel = result.join(); assertNotNull("Expected created tunnel to not be null", createdTunnel); return createdTunnel; } } dev-tunnels-0.0.25/java/src/test/java/com/microsoft/tunnels/TunnelTest.java000066400000000000000000000053431450757157500267220ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. package com.microsoft.tunnels; import com.microsoft.tunnels.contracts.Tunnel; import com.microsoft.tunnels.contracts.TunnelAccessScopes; import com.microsoft.tunnels.management.ProductHeaderValue; import com.microsoft.tunnels.management.TunnelManagementClient; import com.microsoft.tunnels.management.TunnelRequestOptions; import java.util.Arrays; import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; import org.apache.maven.shared.utils.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Base class for java tunnel tests */ public abstract class TunnelTest { protected static ProductHeaderValue userAgent = new ProductHeaderValue("connection-test", TunnelManagementClientTests.class.getPackage().getSpecificationVersion()); // Test tunnel used for local testing of tunnel connections. protected static final String testTunnelName = System.getenv("TEST_TUNNEL_NAME"); // User token for creating tunnels in local tests. protected static final String testToken = System.getenv("TEST_TUNNEL_TOKEN"); protected static final Supplier> userTokenCallback = StringUtils .isNotBlank(testToken) ? () -> CompletableFuture.completedFuture(testToken) : null; protected static TunnelManagementClient tunnelManagementClient = new TunnelManagementClient( new ProductHeaderValue[] { userAgent }, userTokenCallback); protected static final Logger logger = LoggerFactory.getLogger(TunnelTest.class); protected TunnelTest() { if (StringUtils.isNotBlank(System.getenv("TEST_TUNNEL_VERBOSE"))) { enableVerboseLogging(); } } /** * Enables FINE logging for all components (including HTTP, SSL, SSH). VERY * verbose. */ private static void enableVerboseLogging() { // SLF4J logging is routed to java.util.logging via the reference to the // slf4j-jdk14 package. var rootLogger = java.util.logging.Logger.getLogger(""); rootLogger.setLevel(java.util.logging.Level.FINE); // A console log handler is enabled at the root level by default. for (var logHandler : rootLogger.getHandlers()) { logHandler.setLevel(java.util.logging.Level.ALL); } } protected static Tunnel getTestTunnel() { // Set up tunnel Tunnel tunnel = new Tunnel(); tunnel.name = testTunnelName; // Configure tunnel request options var requestOptions = new TunnelRequestOptions(); requestOptions.tokenScopes = Arrays.asList(TunnelAccessScopes.connect); requestOptions.includePorts = true; // get tunnel var result = tunnelManagementClient.getTunnelAsync(tunnel, requestOptions).join(); return result; } } dev-tunnels-0.0.25/rs/000077500000000000000000000000001450757157500145065ustar00rootroot00000000000000dev-tunnels-0.0.25/rs/.gitignore000066400000000000000000000000101450757157500164650ustar00rootroot00000000000000/target dev-tunnels-0.0.25/rs/CONTRIBUTING.md000066400000000000000000000013131450757157500167350ustar00rootroot00000000000000# Contributing Rust contracts are generated from C# code located in the `cs` folder within this repo. They're generated by `RustContractWriter.cs` as part of the build, which you can trigger via: ``` dotnet build --no-incremental ``` We then have some end-to-end tests in the management client. These can be executed by first setting a the `TUNNEL_TEST_CLIENT_ID` to some valid AAD app ID, and then running `cargo test --features end_to_end -- --nocapture`. The first time you run the tests, you will be prompted to log in with device code authentication. ## Code Style and Formatting Before checking in, please run `cargo fmt` to format your changes (or use an IDE with a Rust analyzer to format your files). dev-tunnels-0.0.25/rs/Cargo.lock000066400000000000000000001425351450757157500164250ustar00rootroot00000000000000# This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 3 [[package]] name = "adler" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" [[package]] name = "aead" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c192eb8f11fc081b0fe4259ba5af04217d4e0faddd02417310a927911abd7c8" dependencies = [ "crypto-common", "generic-array", ] [[package]] name = "aes" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfe0133578c0986e1fe3dfcd4af1cc5b2dd6c3dbf534d69916ce16a2701d40ba" dependencies = [ "cfg-if", "cipher", "cpufeatures", ] [[package]] name = "aes-gcm" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82e1366e0c69c9f927b1fa5ce2c7bf9eafc8f9268c0b9800729e8b267612447c" dependencies = [ "aead", "aes", "cipher", "ctr", "ghash", "subtle", ] [[package]] name = "aho-corasick" version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" dependencies = [ "memchr", ] [[package]] name = "async-trait" version = "0.1.56" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96cf8829f67d2eab0b2dfa42c5d0ef737e0724e4a82b01b3e292456202b19716" dependencies = [ "proc-macro2", "quote", "syn 1.0.96", ] [[package]] name = "autocfg" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "base64" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" [[package]] name = "base64ct" version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2b2456fd614d856680dcd9fcc660a51a820fa09daef2e49772b56a193c8474" [[package]] name = "bcrypt-pbkdf" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3806a8db60cf56efee531616a34a6aaa9a114d6da2add861b0fa4a188881b2c7" dependencies = [ "blowfish", "pbkdf2", "sha2 0.10.2", ] [[package]] name = "bit-vec" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "block-buffer" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" dependencies = [ "generic-array", ] [[package]] name = "block-buffer" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324" dependencies = [ "generic-array", ] [[package]] name = "block-padding" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a90ec2df9600c28a01c56c4784c9207a96d2451833aeceb8cc97e4c9548bb78" dependencies = [ "generic-array", ] [[package]] name = "blowfish" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" dependencies = [ "byteorder", "cipher", ] [[package]] name = "bumpalo" version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535" [[package]] name = "byteorder" version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" [[package]] name = "bytes" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" [[package]] name = "cbc" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ "cipher", ] [[package]] name = "cc" version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chacha20" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7fc89c7c5b9e7a02dfe45cd2367bae382f9ed31c61ca8debe5f827c420a2f08" dependencies = [ "cfg-if", "cipher", "cpufeatures", ] [[package]] name = "chrono" version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" dependencies = [ "num-integer", "num-traits", "serde", ] [[package]] name = "cipher" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1873270f8f7942c191139cb8a40fd228da6c3fd2fc376d7e92d47aa14aeb59e" dependencies = [ "crypto-common", "inout", ] [[package]] name = "core-foundation" version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "core-foundation-sys" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" [[package]] name = "cpufeatures" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc948ebb96241bb40ab73effeb80d9f93afaad49359d159a5e61be51619fe813" dependencies = [ "libc", ] [[package]] name = "crc32fast" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-channel" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5aaa7bd5fb665c6864b5f963dd9097905c54125909c7aa94c9e18507cdbe6c53" dependencies = [ "cfg-if", "crossbeam-utils", ] [[package]] name = "crossbeam-utils" version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf124c720b7686e3c2663cf54062ab0f68a88af2fb6a030e87e30bf721fcb38" dependencies = [ "cfg-if", "lazy_static", ] [[package]] name = "crypto-common" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", "rand_core 0.6.3", "typenum", ] [[package]] name = "ctr" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d14f329cfbaf5d0e06b5e87fff7e265d2673c5ea7d2c27691a2c107db1442a0" dependencies = [ "cipher", ] [[package]] name = "curve25519-dalek" version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f9d052967f590a76e62eb387bd0bbb1b000182c3cefe5364db6b7211651bc0" dependencies = [ "byteorder", "digest 0.9.0", "rand_core 0.5.1", "subtle", "zeroize", ] [[package]] name = "data-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ee2393c4a91429dffb4bedf19f4d6abf27d8a732c8ce4980305d782e5426d57" [[package]] name = "digest" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" dependencies = [ "generic-array", ] [[package]] name = "digest" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506" dependencies = [ "block-buffer 0.10.2", "crypto-common", "subtle", ] [[package]] name = "dirs" version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" dependencies = [ "dirs-sys", ] [[package]] name = "dirs-sys" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" dependencies = [ "libc", "redox_users", "winapi", ] [[package]] name = "ed25519" version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9c280362032ea4203659fc489832d0204ef09f247a0506f170dafcac08c369" dependencies = [ "signature", ] [[package]] name = "ed25519-dalek" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c762bae6dcaf24c4c84667b8579785430908723d5c889f469d76a41d59cc7a9d" dependencies = [ "curve25519-dalek", "ed25519", "rand 0.7.3", "serde", "sha2 0.9.9", "zeroize", ] [[package]] name = "encoding_rs" version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9852635589dc9f9ea1b6fe9f05b50ef208c85c834a562f0c6abb1c475736ec2b" dependencies = [ "cfg-if", ] [[package]] name = "fastrand" version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3fcf0cee53519c866c09b5de1f6c56ff9d647101f81c1964fa632e148896cdf" dependencies = [ "instant", ] [[package]] name = "flate2" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f82b0f4c27ad9f8bfd1f3208d882da2b09c301bc1c828fd3a00d0216d2fbbff6" dependencies = [ "crc32fast", "miniz_oxide", ] [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foreign-types" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ "foreign-types-shared", ] [[package]] name = "foreign-types-shared" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" dependencies = [ "matches", "percent-encoding", ] [[package]] name = "futures" version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab30e97ab6aacfe635fad58f22c2bb06c8b685f7421eb1e064a729e2a5f481fa" dependencies = [ "futures-channel", "futures-core", "futures-executor", "futures-io", "futures-sink", "futures-task", "futures-util", ] [[package]] name = "futures-channel" version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" dependencies = [ "futures-core", "futures-sink", ] [[package]] name = "futures-core" version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" [[package]] name = "futures-executor" version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d11aa21b5b587a64682c0094c2bdd4df0076c5324961a40cc3abd7f37930528" dependencies = [ "futures-core", "futures-task", "futures-util", ] [[package]] name = "futures-io" version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" [[package]] name = "futures-macro" version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" dependencies = [ "proc-macro2", "quote", "syn 2.0.37", ] [[package]] name = "futures-sink" version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" [[package]] name = "futures-task" version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" [[package]] name = "futures-util" version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" dependencies = [ "futures-channel", "futures-core", "futures-io", "futures-macro", "futures-sink", "futures-task", "memchr", "pin-project-lite", "pin-utils", "slab", ] [[package]] name = "generic-array" version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bff49e947297f3312447abdca79f45f4738097cc82b06e72054d2223f601f1b9" dependencies = [ "typenum", "version_check", ] [[package]] name = "getrandom" version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" dependencies = [ "cfg-if", "libc", "wasi 0.9.0+wasi-snapshot-preview1", ] [[package]] name = "getrandom" version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" dependencies = [ "cfg-if", "libc", "wasi 0.11.0+wasi-snapshot-preview1", ] [[package]] name = "ghash" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d930750de5717d2dd0b8c0d42c076c0e884c81a73e6cab859bbd2339c71e3e40" dependencies = [ "opaque-debug", "polyval", ] [[package]] name = "h2" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b91535aa35fea1523ad1b86cb6b53c28e0ae566ba4a460f4457e936cad7c6f" dependencies = [ "bytes", "fnv", "futures-core", "futures-sink", "futures-util", "http", "indexmap", "slab", "tokio", "tokio-util", "tracing", ] [[package]] name = "hashbrown" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" [[package]] name = "hermit-abi" version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" dependencies = [ "libc", ] [[package]] name = "hex-literal" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ebdb29d2ea9ed0083cd8cece49bbd968021bd99b0849edb4a9a7ee0fdf6a4e0" [[package]] name = "hmac" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ "digest 0.10.3", ] [[package]] name = "http" version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" dependencies = [ "bytes", "fnv", "itoa", ] [[package]] name = "http-body" version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" dependencies = [ "bytes", "http", "pin-project-lite", ] [[package]] name = "httparse" version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "496ce29bb5a52785b44e0f7ca2847ae0bb839c9bd28f69acac9b99d461c0c04c" [[package]] name = "httpdate" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" [[package]] name = "hyper" version = "0.14.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42dc3c131584288d375f2d07f822b0cb012d8c6fb899a5b9fdb3cb7eb9b6004f" dependencies = [ "bytes", "futures-channel", "futures-core", "futures-util", "h2", "http", "http-body", "httparse", "httpdate", "itoa", "pin-project-lite", "socket2", "tokio", "tower-service", "tracing", "want", ] [[package]] name = "hyper-tls" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ "bytes", "hyper", "native-tls", "tokio", "tokio-native-tls", ] [[package]] name = "idna" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" dependencies = [ "matches", "unicode-bidi", "unicode-normalization", ] [[package]] name = "indexmap" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6012d540c5baa3589337a98ce73408de9b5a25ec9fc2c6fd6be8f0d39e0ca5a" dependencies = [ "autocfg", "hashbrown", ] [[package]] name = "inout" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" dependencies = [ "block-padding", "generic-array", ] [[package]] name = "instant" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" dependencies = [ "cfg-if", ] [[package]] name = "ipnet" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879d54834c8c76457ef4293a689b2a8c59b076067ad77b15efafbb05f92a592b" [[package]] name = "itoa" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d" [[package]] name = "js-sys" version = "0.3.64" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" dependencies = [ "wasm-bindgen", ] [[package]] name = "lazy_static" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" [[package]] name = "lock_api" version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "327fa5b6a6940e4699ec49a9beae1ea4845c6bab9314e4f84ac68742139d8c53" dependencies = [ "autocfg", "scopeguard", ] [[package]] name = "log" version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" dependencies = [ "cfg-if", ] [[package]] name = "matches" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" [[package]] name = "md5" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "memchr" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" [[package]] name = "mime" version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" [[package]] name = "miniz_oxide" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f5c75688da582b8ffc1f1799e9db273f32133c49e048f614d22ec3256773ccc" dependencies = [ "adler", ] [[package]] name = "mio" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "713d550d9b44d89174e066b7a6217ae06234c10cb47819a88290d2b353c31799" dependencies = [ "libc", "log", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys", ] [[package]] name = "native-tls" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" dependencies = [ "lazy_static", "libc", "log", "openssl", "openssl-probe", "openssl-sys", "schannel", "security-framework", "security-framework-sys", "tempfile", ] [[package]] name = "num-bigint" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f93ab6289c7b344a8a9f60f88d80aa20032336fe78da341afc91c8a2341fc75f" dependencies = [ "autocfg", "num-integer", "num-traits", "rand 0.8.5", ] [[package]] name = "num-integer" version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" dependencies = [ "autocfg", "num-traits", ] [[package]] name = "num-traits" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" dependencies = [ "autocfg", ] [[package]] name = "num_cpus" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" dependencies = [ "hermit-abi", "libc", ] [[package]] name = "once_cell" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "074864da206b4973b84eb91683020dbefd6a8c3f0f38e054d93954e891935e4e" [[package]] name = "opaque-debug" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" [[package]] name = "openssl" version = "0.10.55" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "345df152bc43501c5eb9e4654ff05f794effb78d4efe3d53abc158baddc0703d" dependencies = [ "bitflags", "cfg-if", "foreign-types", "libc", "once_cell", "openssl-macros", "openssl-sys", ] [[package]] name = "openssl-macros" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b501e44f11665960c7e7fcf062c7d96a14ade4aa98116c004b2e37b5be7d736c" dependencies = [ "proc-macro2", "quote", "syn 1.0.96", ] [[package]] name = "openssl-probe" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-src" version = "111.25.3+1.1.1t" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "924757a6a226bf60da5f7dd0311a34d2b52283dd82ddeb103208ddc66362f80c" dependencies = [ "cc", ] [[package]] name = "openssl-sys" version = "0.9.90" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "374533b0e45f3a7ced10fcaeccca020e66656bc03dac384f852e4e5a7a8104a6" dependencies = [ "cc", "libc", "openssl-src", "pkg-config", "vcpkg", ] [[package]] name = "opentelemetry" version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9591d937bc0e6d2feb6f71a559540ab300ea49955229c347a517a28d27784c54" dependencies = [ "opentelemetry_api", "opentelemetry_sdk", ] [[package]] name = "opentelemetry_api" version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a81f725323db1b1206ca3da8bb19874bbd3f57c3bcd59471bfb04525b265b9b" dependencies = [ "futures-channel", "futures-util", "indexmap", "js-sys", "once_cell", "pin-project-lite", "thiserror", "urlencoding", ] [[package]] name = "opentelemetry_sdk" version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa8e705a0612d48139799fcbaba0d4a90f06277153e43dd2bdc16c6f0edd8026" dependencies = [ "async-trait", "crossbeam-channel", "futures-channel", "futures-executor", "futures-util", "once_cell", "opentelemetry_api", "ordered-float", "percent-encoding", "rand 0.8.5", "thiserror", ] [[package]] name = "ordered-float" version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a54938017eacd63036332b4ae5c8a49fc8c0c1d6d629893057e4f13609edd06" dependencies = [ "num-traits", ] [[package]] name = "parking_lot" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" dependencies = [ "lock_api", "parking_lot_core", ] [[package]] name = "parking_lot_core" version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09a279cbf25cb0757810394fbc1e359949b59e348145c643a939a525692e6929" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", "windows-sys", ] [[package]] name = "password-hash" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" dependencies = [ "base64ct", "rand_core 0.6.3", "subtle", ] [[package]] name = "pbkdf2" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest 0.10.3", "hmac", "password-hash", "sha2 0.10.2", ] [[package]] name = "percent-encoding" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" [[package]] name = "pin-project-lite" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" [[package]] name = "pin-utils" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkg-config" version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" [[package]] name = "poly1305" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ "cpufeatures", "opaque-debug", "universal-hash", ] [[package]] name = "polyval" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ef234e08c11dfcb2e56f79fd70f6f2eb7f025c0ce2333e82f4f0518ecad30c6" dependencies = [ "cfg-if", "cpufeatures", "opaque-debug", "universal-hash", ] [[package]] name = "ppv-lite86" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" [[package]] name = "proc-macro2" version = "1.0.67" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d433d9f1a3e8c1263d9456598b16fec66f4acc9a74dacffd35c7bb09b3a1328" dependencies = [ "unicode-ident", ] [[package]] name = "quote" version = "1.0.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" dependencies = [ "proc-macro2", ] [[package]] name = "rand" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" dependencies = [ "getrandom 0.1.16", "libc", "rand_chacha 0.2.2", "rand_core 0.5.1", "rand_hc", ] [[package]] name = "rand" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha 0.3.1", "rand_core 0.6.3", ] [[package]] name = "rand_chacha" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" dependencies = [ "ppv-lite86", "rand_core 0.5.1", ] [[package]] name = "rand_chacha" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", "rand_core 0.6.3", ] [[package]] name = "rand_core" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" dependencies = [ "getrandom 0.1.16", ] [[package]] name = "rand_core" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" dependencies = [ "getrandom 0.2.7", ] [[package]] name = "rand_hc" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" dependencies = [ "rand_core 0.5.1", ] [[package]] name = "redox_syscall" version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42" dependencies = [ "bitflags", ] [[package]] name = "redox_users" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" dependencies = [ "getrandom 0.2.7", "redox_syscall", "thiserror", ] [[package]] name = "regex" version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48aaa5748ba571fb95cd2c85c09f629215d3a6ece942baa100950af03a34f733" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] [[package]] name = "regex-syntax" version = "0.6.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" [[package]] name = "remove_dir_all" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" dependencies = [ "winapi", ] [[package]] name = "reqwest" version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b75aa69a3f06bbcc66ede33af2af253c6f7a86b1ca0033f60c580a27074fbf92" dependencies = [ "base64", "bytes", "encoding_rs", "futures-core", "futures-util", "h2", "http", "http-body", "hyper", "hyper-tls", "ipnet", "js-sys", "lazy_static", "log", "mime", "native-tls", "percent-encoding", "pin-project-lite", "serde", "serde_json", "serde_urlencoded", "tokio", "tokio-native-tls", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", "winreg", ] [[package]] name = "russh" version = "0.37.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7894d12dc117ca07eb565bcf75aac708b6ed5e2fd6a47e54a2ce42b64b5eea0" dependencies = [ "aes", "aes-gcm", "async-trait", "bitflags", "byteorder", "chacha20", "ctr", "curve25519-dalek", "digest 0.10.3", "flate2", "futures", "generic-array", "hex-literal", "hmac", "log", "num-bigint", "once_cell", "openssl", "poly1305", "rand 0.8.5", "russh-cryptovec", "russh-keys", "sha1", "sha2 0.10.2", "subtle", "thiserror", "tokio", "tokio-util", ] [[package]] name = "russh-cryptovec" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3fdf036c2216b554053d19d4af45c1722d13b00ac494ea19825daf4beac034e" dependencies = [ "libc", "winapi", ] [[package]] name = "russh-keys" version = "0.37.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2efecb132e085e1e7a3afdae40e19eec0dfd4d6159eb189de02f243c369e18b7" dependencies = [ "aes", "bcrypt-pbkdf", "bit-vec", "block-padding", "byteorder", "cbc", "ctr", "data-encoding", "dirs", "ed25519-dalek", "futures", "hmac", "inout", "log", "md5", "num-bigint", "num-integer", "openssl", "pbkdf2", "rand 0.7.3", "rand_core 0.5.1", "russh-cryptovec", "serde", "sha2 0.10.2", "thiserror", "tokio", "tokio-stream", "yasna", ] [[package]] name = "ryu" version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3f6f92acf49d1b98f7a81226834412ada05458b7364277387724a237f062695" [[package]] name = "schannel" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88d6731146462ea25d9244b2ed5fd1d716d25c52e4d54aa4fb0f3c4e9854dbe2" dependencies = [ "lazy_static", "windows-sys", ] [[package]] name = "scopeguard" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] name = "security-framework" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dc14f172faf8a0194a3aded622712b0de276821addc574fa54fc0a1167e10dc" dependencies = [ "bitflags", "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", ] [[package]] name = "security-framework-sys" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0160a13a177a45bfb43ce71c01580998474f556ad854dcbca936dd2841a5c556" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "serde" version = "1.0.137" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61ea8d54c77f8315140a05f4c7237403bf38b72704d031543aa1d16abbf517d1" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.137" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f26faba0c3959972377d3b2d306ee9f71faee9714294e41bb777f83f88578be" dependencies = [ "proc-macro2", "quote", "syn 1.0.96", ] [[package]] name = "serde_json" version = "1.0.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b7ce2b32a1aed03c558dc61a5cd328f15aff2dbc17daad8fb8af04d2100e15c" dependencies = [ "itoa", "ryu", "serde", ] [[package]] name = "serde_urlencoded" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ "form_urlencoded", "itoa", "ryu", "serde", ] [[package]] name = "sha1" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c77f4e7f65455545c2153c1253d25056825e77ee2533f0e41deb65a93a34852f" dependencies = [ "cfg-if", "cpufeatures", "digest 0.10.3", ] [[package]] name = "sha2" version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", "cfg-if", "cpufeatures", "digest 0.9.0", "opaque-debug", ] [[package]] name = "sha2" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55deaec60f81eefe3cce0dc50bda92d6d8e88f2a27df7c5033b42afeb1ed2676" dependencies = [ "cfg-if", "cpufeatures", "digest 0.10.3", ] [[package]] name = "signal-hook-registry" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" dependencies = [ "libc", ] [[package]] name = "signature" version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0ea32af43239f0d353a7dd75a22d94c329c8cdaafdcb4c1c1335aa10c298a4a" [[package]] name = "slab" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb703cfe953bccee95685111adeedb76fabe4e97549a58d16f03ea7b9367bb32" [[package]] name = "smallvec" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd0db749597d91ff862fd1d55ea87f7855a744a8425a64695b6fca237d1dad1" [[package]] name = "socket2" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66d72b759436ae32898a2af0a14218dbf55efde3feeb170eb623637db85ee1e0" dependencies = [ "libc", "winapi", ] [[package]] name = "subtle" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" [[package]] name = "syn" version = "1.0.96" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0748dd251e24453cb8717f0354206b91557e4ec8703673a4b30208f2abaf1ebf" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "syn" version = "2.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7303ef2c05cd654186cb250d29049a24840ca25d2747c25c0381c8d9e2f582e8" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "synstructure" version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" dependencies = [ "proc-macro2", "quote", "syn 1.0.96", "unicode-xid", ] [[package]] name = "tempfile" version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" dependencies = [ "cfg-if", "fastrand", "libc", "redox_syscall", "remove_dir_all", "winapi", ] [[package]] name = "thiserror" version = "1.0.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" version = "1.0.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a" dependencies = [ "proc-macro2", "quote", "syn 1.0.96", ] [[package]] name = "tinyvec" version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" dependencies = [ "tinyvec_macros", ] [[package]] name = "tinyvec_macros" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" version = "1.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb78f30e4b41e98ca4cce5acb51168a033839a7af9e42b380355808e14e98ee0" dependencies = [ "autocfg", "bytes", "libc", "memchr", "mio", "num_cpus", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", "winapi", ] [[package]] name = "tokio-macros" version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9724f9a975fb987ef7a3cd9be0350edcbe130698af5b8f7a631e23d42d052484" dependencies = [ "proc-macro2", "quote", "syn 1.0.96", ] [[package]] name = "tokio-native-tls" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" dependencies = [ "native-tls", "tokio", ] [[package]] name = "tokio-stream" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df54d54117d6fdc4e4fea40fe1e4e566b3505700e148a6827e59b34b0d2600d9" dependencies = [ "futures-core", "pin-project-lite", "tokio", ] [[package]] name = "tokio-tungstenite" version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" dependencies = [ "futures-util", "log", "native-tls", "tokio", "tokio-native-tls", "tungstenite", ] [[package]] name = "tokio-util" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc463cd8deddc3770d20f9852143d50bf6094e640b485cb2e189a2099085ff45" dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio", "tracing", ] [[package]] name = "tower-service" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6" [[package]] name = "tracing" version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a400e31aa60b9d44a52a8ee0343b5b18566b03a8321e0d321f695cf56e940160" dependencies = [ "cfg-if", "pin-project-lite", "tracing-core", ] [[package]] name = "tracing-core" version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7709595b8878a4965ce5e87ebf880a7d39c9afc6837721b21a5a816a8117d921" dependencies = [ "once_cell", ] [[package]] name = "try-lock" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" [[package]] name = "tungstenite" version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" dependencies = [ "byteorder", "bytes", "data-encoding", "http", "httparse", "log", "native-tls", "rand 0.8.5", "sha1", "thiserror", "url", "utf-8", ] [[package]] name = "tunnels" version = "0.1.0" dependencies = [ "async-trait", "chrono", "futures", "hyper", "log", "opentelemetry", "rand 0.8.5", "regex", "reqwest", "russh", "russh-keys", "serde", "serde_json", "thiserror", "tokio", "tokio-tungstenite", "tokio-util", "tungstenite", "url", "uuid", ] [[package]] name = "typenum" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" [[package]] name = "unicode-bidi" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" [[package]] name = "unicode-ident" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d22af068fba1eb5edcb4aea19d382b2a3deb4c8f9d475c589b6ada9e0fd493ee" [[package]] name = "unicode-normalization" version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" dependencies = [ "tinyvec", ] [[package]] name = "unicode-xid" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "957e51f3646910546462e67d5f7599b9e4fb8acdd304b087a6494730f9eebf04" [[package]] name = "universal-hash" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d3160b73c9a19f7e2939a2fdad446c57c1bbbbf4d919d3213ff1267a580d8b5" dependencies = [ "crypto-common", "subtle", ] [[package]] name = "url" version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" dependencies = [ "form_urlencoded", "idna", "matches", "percent-encoding", ] [[package]] name = "urlencoding" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] name = "utf-8" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[package]] name = "uuid" version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d" dependencies = [ "getrandom 0.2.7", ] [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version_check" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" [[package]] name = "want" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" dependencies = [ "log", "try-lock", ] [[package]] name = "wasi" version = "0.9.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" dependencies = [ "cfg-if", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" dependencies = [ "bumpalo", "log", "once_cell", "proc-macro2", "quote", "syn 2.0.37", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de9a9cec1733468a8c657e57fa2413d2ae2c0129b95e87c5b72b8ace4d13f31f" dependencies = [ "cfg-if", "js-sys", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" dependencies = [ "quote", "wasm-bindgen-macro-support", ] [[package]] name = "wasm-bindgen-macro-support" version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" dependencies = [ "proc-macro2", "quote", "syn 2.0.37", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" [[package]] name = "web-sys" version = "0.3.58" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fed94beee57daf8dd7d51f2b15dc2bcde92d7a72304cdf662a4371008b71b90" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] name = "winapi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ "winapi-i686-pc-windows-gnu", "winapi-x86_64-pc-windows-gnu", ] [[package]] name = "winapi-i686-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-sys" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" dependencies = [ "windows_aarch64_msvc", "windows_i686_gnu", "windows_i686_msvc", "windows_x86_64_gnu", "windows_x86_64_msvc", ] [[package]] name = "windows_aarch64_msvc" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" [[package]] name = "windows_i686_gnu" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" [[package]] name = "windows_i686_msvc" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" [[package]] name = "windows_x86_64_gnu" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" [[package]] name = "windows_x86_64_msvc" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" [[package]] name = "winreg" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" dependencies = [ "winapi", ] [[package]] name = "yasna" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" dependencies = [ "bit-vec", "num-bigint", ] [[package]] name = "zeroize" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4756f7db3f7b5574938c3eb1c117038b8e07f95ee6718c0efad4ac21508f1efd" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f8f187641dad4f680d25c4bfc4225b418165984179f26ca76ec4fb6441d3a17" dependencies = [ "proc-macro2", "quote", "syn 1.0.96", "synstructure", ] dev-tunnels-0.0.25/rs/Cargo.toml000066400000000000000000000031011450757157500164310ustar00rootroot00000000000000[package] name = "tunnels" version = "0.1.0" edition = "2021" [dependencies] serde = { version = "1", features = ["derive"] } chrono = { version = "0.4", features = ["serde"], default-features = false } reqwest = { version = "0.11", features = ["default", "json"] } url = "2" opentelemetry = { version = "0.20", features = ["trace"], optional = true } serde_json = "1" async-trait = "0.1" thiserror = "1.0" log = "0.4" tokio = { version = "1.20", features = ["macros", "io-util", "time"], optional = true } tokio-util = { version = "0.7", optional = true } tokio-tungstenite = { version = "0.20", optional = true, features = ["native-tls"] } futures = { version = "0.3", optional = true } tungstenite = { version = "0.20", optional = true, features = ["native-tls"] } uuid = { version = "1.4", features = ["v4"], optional = true } russh = { version = "0.37.1", default-features = false, features = ["openssl", "flate2"], optional = true } russh-keys = { version = "0.37.1", default-features = false, features = ["openssl"], optional = true } hyper = "0.14" [dev-dependencies] tokio = { version = "1.20", features = ["full"] } rand = "0.8" regex = "1" [features] default = [] end_to_end = [] instrumentation = ["dep:opentelemetry"] connections = [ "dep:tokio", "dep:tokio-util", "dep:futures", "dep:tokio-tungstenite", "dep:tungstenite", "dep:uuid", "dep:russh", "dep:russh-keys", ] vendored-openssl = [ "reqwest/native-tls-vendored", "tokio-tungstenite?/native-tls-vendored", "tungstenite?/native-tls-vendored", "russh?/vendored-openssl", "russh-keys?/vendored-openssl" ] dev-tunnels-0.0.25/rs/src/000077500000000000000000000000001450757157500152755ustar00rootroot00000000000000dev-tunnels-0.0.25/rs/src/connections/000077500000000000000000000000001450757157500176175ustar00rootroot00000000000000dev-tunnels-0.0.25/rs/src/connections/errors.rs000066400000000000000000000020211450757157500214740ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. use thiserror::Error; /// Type of error returned from tunnel operations. #[derive(Debug, Error)] pub enum TunnelError { #[error("{reason}: {error}")] HttpError { error: crate::management::HttpError, reason: &'static str, }, #[error("the tunnel relay was disconnected: {0}")] TunnelRelayDisconnected(#[from] russh::Error), #[error("the tunnel host relay endpoint URI is missing")] MissingHostEndpoint, #[error("invalid host relay uri: {0}")] InvalidHostEndpoint(String), #[error("websocket error: {0}")] WebSocketError(#[from] tungstenite::Error), #[error("port {0} already exists in the relay")] PortAlreadyExists(u32), #[error("proxy connection failed: {0}")] ProxyConnectionFailed(std::io::Error), #[error("proxy handshake failed: {0}")] ProxyHandshakeFailed(hyper::Error), #[error("proxy connect request failed: {0}")] ProxyConnectRequestFailed(hyper::Error) } dev-tunnels-0.0.25/rs/src/connections/io.rs000066400000000000000000000024321450757157500205750ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. use std::task::Poll; /// Helper used when converting Future interfaces to poll-based interfaces. /// Stores excess data that can be reused on future polls. #[derive(Default)] pub(crate) struct ReadBuffer(Option<(Vec, usize)>); impl ReadBuffer { /// Removes any data stored in the read buffer pub fn take_data(&mut self) -> Option<(Vec, usize)> { self.0.take() } /// Writes as many bytes as possible to the readbuf, stashing any extra. pub fn put_data( &mut self, target: &mut tokio::io::ReadBuf<'_>, bytes: Vec, start: usize, ) -> Poll> { if bytes.is_empty() { self.0 = None; // should not return Ok(), since if nothing is written to the target // it signals EOF. Instead wait for more data from the source. return Poll::Pending; } if target.remaining() >= bytes.len() - start { target.put_slice(&bytes[start..]); self.0 = None; } else { let end = start + target.remaining(); target.put_slice(&bytes[start..end]); self.0 = Some((bytes, end)); } Poll::Ready(Ok(())) } } dev-tunnels-0.0.25/rs/src/connections/mod.rs000066400000000000000000000002361450757157500207450ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. mod errors; mod io; mod relay_tunnel_host; mod ws; pub use relay_tunnel_host::*; dev-tunnels-0.0.25/rs/src/connections/relay_tunnel_host.rs000066400000000000000000001126151450757157500237310ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. use std::{ collections::HashMap, env, io, net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, pin::Pin, sync::Arc, task::Poll, time::Duration, }; use crate::{ contracts::{TunnelConnectionMode, TunnelEndpoint, TunnelPort, TunnelRelayTunnelEndpoint}, management::{ Authorization, HttpError, TunnelLocator, TunnelManagementClient, TunnelRequestOptions, NO_REQUEST_OPTIONS, }, }; use async_trait::async_trait; use futures::{stream::FuturesUnordered, StreamExt, TryFutureExt}; use russh::{server::Server as ServerTrait, CryptoVec}; use tokio::{ io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, net::TcpStream, sync::{mpsc, oneshot, watch}, task::JoinHandle, }; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use uuid::Uuid; use super::{ errors::TunnelError, ws::{build_websocket_request, connect_directly, connect_via_proxy, AsyncRWWebSocket}, }; /// Mapping of port numbers to senders to which new port connections should be /// sent. Shared by the host relay to each connected session. type PortMap = HashMap>; /// The RelayTunnelHost can host connections via the tunneling service. After /// creating it, you will generally want to run `connect()` to create a new /// a new connection. /// /// Note that, while ports can be added and remove dynamically from running /// tunnels via the appropriate methods on the RelayTunnelHost, no ports will be /// hosted until those methods are called. pub struct RelayTunnelHost { pub proxy: Option, locator: TunnelLocator, host_id: Uuid, ports_tx: watch::Sender, ports_rx: watch::Receiver, mgmt: TunnelManagementClient, host_keypair: russh_keys::key::KeyPair, } /// Hello friend. You're probably here because you want to change how tunnel /// connections work! Here's how it works. /// /// ## Overall Communication /// /// Tunneling communicates via SSH over websockets. Data is sent fairly opaquely in /// binary websocket messages. We use Tungstenite for the websocket, and then /// wrap it into an AsyncRead/AsyncWrite type that we can give to the websocket /// library, russh. /// /// We then connect over SSH. Today, Tunneling doesn't require additional auth in /// the SSH connection, and also has a somewhat non-standard "none" value for /// its key exchange. This prevents many off-the-shelf SSH implementations from /// working. Once a client connects to the other end of the tunnel, it'll /// open a new Channel from the server (of type "client-ssh-session-stream"). /// /// The stream of data over this channel is actually an SSH client connection. /// So, for each client, we create an SSH server instance, and do some more /// work to make that channel AsyncRead/AsyncWrite as well. (Note that this /// client actually does do a key exchange and it data is encrypted.) Once /// established, the host (this program) requests the client to forward the /// ports that it via `tcpip-forward` requests. Clients then open /// `forwarded-tcpip` channels for each new connection. /// /// ```text /// ┌───────────┐ ┌───────┐ ┌───────┐ /// │Host (this)│ │Service│ │Client │ /// └─────┬─────┘ └───┬───┘ └───┬───┘ /// │ Connect as │ │ /// ├─SSH client────▶ │ /// │ │ Connect to │ /// │ ◀──service ws──┤ /// │ Create new │ │ /// ◀───SSH tunnel──┤ │ /// │ │ │ /// │ SSH server handshake. │ /// ├────(Service just proxies ────▶ /// │ traffic through) │ /// │ │ │ /// ├────tcpip-forward for ports───▶ /// │ │ │ /// │ │ ◀───asked to /// │ │ │ connect /// ◀────create forwarded-tcpip ───┤ /// make │ channel │ /// local tcp ◀──┤ │ │ /// connection │ │ │ /// ◀ ─ ─ ─ ─forward traffic─ ─ ─ ─▶ /// │ │ │ /// │ │ │ /// ▼ ▼ ▼ /// ``` /// /// ## How this Package Works /// /// The RelayTunnelHost allows the consumer to `connect()` to a tunnel. It's legal /// to call this in parallel (though not generally useful...) The host keeps /// a map of the forwarded ports in a tokio watch channel, which allows /// connected channels to update them in realtime. Each port also has a channel /// on which incoming connections can be received. /// /// When the client creates a new channel for a client, that's sent back on /// a channel, where it's wrapepd in the "AsyncRWChannel" to provide /// AsyncRead/Write traits, and then spawned in its own Tokio task. /// /// It watches the ports list, and when new forwarded channels are made, it /// wraps those in a ForwardedPortConnection struct, and sends those to the /// channel on the port record. /// /// Ports are handled by a client calling `add_port()` or `add_port_raw()`, /// which either forward to a local TCP connection or return the /// ForwardedPortConnection directly, respectively. #[allow(dead_code)] impl RelayTunnelHost { pub fn new(locator: TunnelLocator, mgmt: TunnelManagementClient) -> Self { let host_id = Uuid::new_v4(); let (ports_tx, ports_rx) = watch::channel(HashMap::new()); RelayTunnelHost { proxy: env::var("HTTPS_PROXY").or(env::var("https_proxy")).ok(), host_id, locator, ports_tx, ports_rx, mgmt, host_keypair: russh_keys::key::KeyPair::generate_rsa( 2048, russh_keys::key::SignatureHash::SHA2_512, ) .expect("expected to generate rsa keypair"), } } /// Creates a connection and returns a handle to the tunnel relay. When /// created, the tunnel will forward all ports currently on the tunnel. /// The returned handle is a future that completes when the tunnel closes. /// For example: /// /// ```ignore /// let handle = relay.connect("host_token").await?; /// /// tokio::spawn(async move || { /// loop { /// tokio::select! { /// port = add_port.recv() => handle.add_port(port).await?; /// handle => break, /// } /// } /// }); /// ``` /// /// The handle may be dropped in order to disconnect from the relay, and /// will be closed if connection to the relay fails. Consumers should /// reconnect if this happens, and they can reconnect using the same /// RelayTunnelHost. pub async fn connect(&mut self, host_token: &str) -> Result { let (cnx, endpoint) = self.create_websocket(host_token).await?; let cnx = AsyncRWWebSocket::new(super::ws::AsyncRWWebSocketOptions { websocket: cnx, ping_interval: Duration::from_secs(60), ping_timeout: Duration::from_secs(10), }); let (client_session, mut rx) = RelayTunnelHost::make_ssh_client(cnx) .await .map_err(TunnelError::TunnelRelayDisconnected)?; let client_session = Arc::new(client_session); let client_session_ret = client_session.clone(); log::debug!("established host relay primary session"); let mut channels = HashMap::new(); let ports_rx = self.ports_rx.clone(); let host_keypair = self.host_keypair.clone(); let join = tokio::spawn(async move { let mut server = RelayTunnelHost::make_ssh_server(host_keypair.clone()); loop { tokio::select! { Some(op) = rx.recv() => match op { ChannelOp::Open(id) => { let (rw, sender) = AsyncRWChannel::new(id, client_session.clone()); server.run_stream(rw, ports_rx.clone()); // do we need to store the JoinHandle for any reason? channels.insert(id, sender); log::info!("Opened new client on channel {}", id); }, ChannelOp::Close(id) => { channels.remove(&id); }, ChannelOp::Data(id, data) => { if let Some(ch) = channels.get(&id) { if ch.send(data).is_err() { // rx was dropped channels.remove(&id); } } }, }, else => break, } } client_session .disconnect(russh::Disconnect::ByApplication, "going away", "en") .await .ok(); log::debug!("disconnected primary session after EOF"); Ok(()) }); Ok(RelayHandle { endpoint, join, session: client_session_ret, }) } /// Unregisters relay from the tunnel's list of hosts. pub async fn unregister(&self) -> Result { self.mgmt .delete_tunnel_endpoints( &self.locator, &self.host_id.to_string(), None, NO_REQUEST_OPTIONS, ) .await .map_err(|e| TunnelError::HttpError { error: e, reason: "could not unregister relay", }) } /// Adds a new port to the relay and returns a receiver for connections /// that are made to that port. This is a "low level" type that you can use /// if you want to deal with forwarding manually, but the `add_port` method /// is appropriate and simpler for most use cases. /// /// Calling this method multiple times with the same port will result in /// an error. Dropping the receiver **will not** remove the port, you must /// call `remove_port()` to do that. pub async fn add_port_raw( &self, port_to_add: &TunnelPort, ) -> Result, TunnelError> { let n = port_to_add.port_number as u32; if self.ports_tx.borrow().get(&n).is_some() { return Err(TunnelError::PortAlreadyExists(n)); } let tunnel_port = self .mgmt .create_tunnel_port(&self.locator, port_to_add, NO_REQUEST_OPTIONS) .await; match tunnel_port { // created the port: Ok(_) => {} // the port's already registered, nothing we need to do: Err(HttpError::ResponseError(e)) if e.status_code == 409 => {} Err(e) => { return Err(TunnelError::HttpError { error: e, reason: "failed to add port to tunnel", }) } } let (tx, rx) = mpsc::unbounded_channel(); self.ports_tx.send_modify(|v| { v.insert(n, tx); }); Ok(rx) } /// Adds a new port to the tunnel and forwards TCP/IP connections made /// over that port to the local machine. Calling this method multiple times /// with the same port will result in an error. pub async fn add_port(&self, port_to_add: &TunnelPort) -> Result<(), TunnelError> { let rx = self.add_port_raw(port_to_add).await?; tokio::spawn(forward_port_to_tcp(port_to_add.port_number, rx)); Ok(()) } /// Removes a port from the tunnel connection. Any channel returned from /// `add_port_raw`, and any connections made within `add_port`, will close /// shortly after this is called. pub async fn remove_port(&self, port_number: u16) -> Result<(), TunnelError> { self.mgmt .delete_tunnel_port(&self.locator, port_number, NO_REQUEST_OPTIONS) .await .map_err(|e| TunnelError::HttpError { error: e, reason: "failed to remove port from tunnel", })?; self.ports_tx.send_modify(|v| { v.remove(&(port_number as u32)); }); Ok(()) } fn make_ssh_server(keypair: russh_keys::key::KeyPair) -> Server { let c = russh::server::Config { connection_timeout: None, auth_rejection_time: std::time::Duration::from_secs(5), keys: vec![keypair], window_size: 1024 * 1024 * 1, preferred: russh::Preferred::COMPRESSED, limits: russh::Limits { rekey_read_limit: usize::MAX, rekey_time_limit: Duration::MAX, rekey_write_limit: usize::MAX, }, ..Default::default() }; let config = Arc::new(c); Server { config } } async fn make_ssh_client( rw: impl AsyncRead + AsyncWrite + Unpin + Send + 'static, ) -> Result< ( russh::client::Handle, mpsc::UnboundedReceiver, ), russh::Error, > { let config = russh::client::Config { anonymous: true, window_size: 1024 * 1024 * 5, preferred: russh::Preferred { kex: &[russh::kex::NONE], key: &[russh_keys::key::NONE], cipher: &[russh::cipher::NONE], mac: russh::Preferred::DEFAULT.mac, compression: &["none"], }, limits: russh::Limits { rekey_read_limit: 1024 * 1024 * 8, rekey_time_limit: std::time::Duration::from_secs(60), rekey_write_limit: 1024 * 1024 * 8, }, ..Default::default() }; let config = Arc::new(config); let (client, rx) = Client::new(); let session = russh::client::connect_stream(config, rw, client).await?; Ok((session, rx)) } async fn create_websocket( &self, host_token: &str, ) -> Result< ( WebSocketStream>, TunnelRelayTunnelEndpoint, ), TunnelError, > { let endpoint = self .mgmt .update_tunnel_relay_endpoints( &self.locator, &TunnelRelayTunnelEndpoint { base: TunnelEndpoint { id: Some(uuid::Uuid::new_v4().to_string()), connection_mode: TunnelConnectionMode::TunnelRelay, host_id: self.host_id.to_string(), host_public_keys: vec![], port_uri_format: None, port_ssh_command_format: None, ssh_gateway_public_key: None, tunnel_ssh_command: None, tunnel_uri: None, }, client_relay_uri: None, host_relay_uri: None, }, &TunnelRequestOptions { authorization: Some(Authorization::Tunnel(host_token.to_string())), ..TunnelRequestOptions::default() }, ) .await .map_err(|e| TunnelError::HttpError { error: e, reason: "failed to update tunnel endpoint for hosting", })?; let url = endpoint .host_relay_uri .as_deref() .ok_or(TunnelError::MissingHostEndpoint)?; let req = build_websocket_request( url, &[ ("Sec-WebSocket-Protocol", "tunnel-relay-host"), ("Authorization", &format!("tunnel {}", host_token)), ("User-Agent", self.mgmt.user_agent.to_str().unwrap()), ], )?; let cnx = if let Some(proxy) = &self.proxy { log::debug!("connecting via http_proxy on {}", proxy); connect_via_proxy(req, proxy).await? } else { connect_directly(req).await? }; Ok((cnx, endpoint)) } } /// Type returned in a channel from `add_forwarded_port_raw`, implementing /// `AsyncRead` and `AsyncWrite`. pub struct ForwardedPortConnection { port: u32, channel: russh::ChannelId, handle: russh::server::Handle, receiver: mpsc::Receiver>, } impl ForwardedPortConnection { /// Sends data on the connection. pub async fn send(&mut self, d: &[u8]) -> Result<(), ()> { self.handle .data(self.channel, CryptoVec::from_slice(d)) .map_err(|_| ()) .await } /// Receives data from the connection, returning None when it's closed. pub async fn recv(&mut self) -> Option> { self.receiver.recv().await } /// Closes the forwarded connection. pub async fn close(self) { self.handle.close(self.channel).await.ok(); } /// Returns an AsyncRead/AsyncWrite implementation for the connection. pub fn into_rw(self) -> ForwardedPortRW { let (w, r) = self.into_split(); ForwardedPortRW(r, w) } /// Returns a split AsyncRead/AsyncWrite half for the connection. pub fn into_split(self) -> (ForwardedPortWriter, ForwardedPortReader) { ( ForwardedPortWriter { channel: self.channel, handle: self.handle, is_write_fut_valid: false, write_fut: tokio_util::sync::ReusableBoxFuture::new(make_server_write_fut(None)), }, ForwardedPortReader { receiver: self.receiver, readbuf: super::io::ReadBuffer::default(), }, ) } } /// AsyncWrite implementation that can be obtained from the ForwardedPortConnection. pub struct ForwardedPortWriter { channel: russh::ChannelId, handle: russh::server::Handle, is_write_fut_valid: bool, write_fut: tokio_util::sync::ReusableBoxFuture<'static, Result<(), russh::CryptoVec>>, } /// Makes a future that writes to the russh handle. This general approach was /// taken from https://docs.rs/tokio-util/0.7.3/tokio_util/sync/struct.PollSender.html /// This is just like make_client_write_fut, but for clients (they don't share a trait...) async fn make_server_write_fut( data: Option<(russh::server::Handle, russh::ChannelId, Vec)>, ) -> Result<(), russh::CryptoVec> { match data { Some((client, id, data)) => client.data(id, CryptoVec::from(data)).await, None => unreachable!("this future should not be pollable in this state"), } } impl AsyncWrite for ForwardedPortWriter { fn poll_write( mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &[u8], ) -> Poll> { if !self.is_write_fut_valid { let handle = self.handle.clone(); let id = self.channel; self.write_fut .set(make_server_write_fut(Some((handle, id, buf.to_vec())))); self.is_write_fut_valid = true; } self.poll_flush(cx).map(|r| r.map(|_| buf.len())) } fn poll_flush( mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> Poll> { if !self.is_write_fut_valid { return Poll::Ready(Ok(())); } match self.write_fut.poll(cx) { Poll::Pending => Poll::Pending, Poll::Ready(Ok(_)) => { self.is_write_fut_valid = false; Poll::Ready(Ok(())) } Poll::Ready(Err(_)) => { self.is_write_fut_valid = false; Poll::Ready(Err(io::Error::new(io::ErrorKind::Other, "EOF"))) } } } fn poll_shutdown( self: Pin<&mut Self>, _cx: &mut std::task::Context<'_>, ) -> Poll> { Poll::Ready(Ok(())) } } /// AsyncRead implementation that can be obtained from the ForwardedPortConnection. pub struct ForwardedPortReader { receiver: mpsc::Receiver>, readbuf: super::io::ReadBuffer, } impl AsyncRead for ForwardedPortReader { fn poll_read( mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &mut tokio::io::ReadBuf<'_>, ) -> Poll> { if let Some((v, s)) = self.readbuf.take_data() { return self.readbuf.put_data(buf, v, s); } match self.receiver.poll_recv(cx) { Poll::Ready(Some(msg)) => self.readbuf.put_data(buf, msg, 0), Poll::Ready(None) => Poll::Ready(Err(io::Error::new(io::ErrorKind::Other, "EOF"))), Poll::Pending => Poll::Pending, } } } pub struct ForwardedPortRW(ForwardedPortReader, ForwardedPortWriter); impl AsyncRead for ForwardedPortRW { fn poll_read( mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &mut tokio::io::ReadBuf<'_>, ) -> Poll> { Pin::new(&mut self.0).poll_read(cx, buf) } } impl AsyncWrite for ForwardedPortRW { fn poll_write( mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &[u8], ) -> Poll> { Pin::new(&mut self.1).poll_write(cx, buf) } fn poll_flush( mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> Poll> { Pin::new(&mut self.1).poll_flush(cx) } fn poll_shutdown( mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> Poll> { Pin::new(&mut self.1).poll_shutdown(cx) } } #[derive(Clone)] struct Server { config: Arc, } impl Server { pub fn run_stream( &mut self, rw: impl AsyncRead + AsyncWrite + Unpin + Send + 'static, mut ports: watch::Receiver, ) -> JoinHandle> { let mut server_session = self.new_client(None); let mut server_connection_rx = server_session.take_rx().expect("expected to have tx"); let authed_tx = server_session.take_authed().expect("expected to have tx"); let config = self.config.clone(); tokio::spawn(async move { log::debug!("starting to serve host relay client session"); let session = match russh::server::run_stream(config, rw, server_session).await { Ok(s) => s, Err(e) => { log::error!("error handshaking session: {}", e); return Err(e); } }; if authed_tx.await.is_err() { log::debug!("connection closed before auth"); return Ok(()); // session closed } log::debug!("host relay client session successfully authed"); let mut known_ports: PortMap = HashMap::new(); tokio::pin!(session); loop { tokio::select! { r = &mut session => return r, cnx = server_connection_rx.recv() => match cnx { Some(cnx) => { if let Some(p) = known_ports.get(&cnx.port) { p.send(cnx).ok(); // ignore error, could have dropped in the meantime } }, None => { log::debug!("no more connections on host relay client session, ending"); return Ok(()); }, }, _ = ports.changed() => { let new_ports = ports.borrow().clone(); for port in new_ports.keys() { if !known_ports.contains_key(port) { session.handle().forward_tcpip("127.0.0.1".to_string(), *port).await.ok(); } } for port in known_ports.keys() { if !new_ports.contains_key(port) { session.handle().cancel_forward_tcpip("127.0.0.1".to_string(), *port).await.ok(); } } known_ports = new_ports; }, } } }) } } /// Connects connections that are sent to the receiver to TCP services locally. /// Runs until the receiver is closed (usually via `delete_port()`). async fn forward_port_to_tcp(port: u16, mut rx: mpsc::UnboundedReceiver) { let ipv4_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); let ipv6_addr = SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), port); while let Some(mut conn) = rx.recv().await { // Try connecting to ipv4 and ipv6 in parallel, using the first successful // connection in the stream. A downside is that it means if different sevices // are listening on the ports, the forwarded application is non-deterministic. // // But that's rare, and in other cases, one interface will time out and // one interface will work, so this lets us respond as quickly as possible. let mut futs = FuturesUnordered::new(); futs.push(TcpStream::connect(&ipv4_addr)); futs.push(TcpStream::connect(&ipv6_addr)); let mut last_result = None; while let Some(r) = futs.next().await { let ok = r.is_ok(); last_result = Some(r); // stop on first successful: if ok { break; } } // unwrap is safe since we know there will be at least one result: let mut stream = match last_result.unwrap() { Ok(s) => s, Err(e) => { log::info!("Error connecting forwarding to port {}, {}", port, e); conn.close().await; continue; } }; log::debug!("Forwarded connection to port {}", port); tokio::spawn(async move { let mut read_buf = vec![0u8; 1024 * 64].into_boxed_slice(); loop { tokio::select! { n = stream.read(&mut read_buf) => match n { Ok(0) => { log::debug!("EOF from TCP stream, ending"); break; }, Ok(n) => { if (conn.send(&read_buf[..n]).await).is_err() { log::debug!("channel was closed, ending forwarded port"); break; } }, Err(e) => { log::debug!("error from TCP stream, ending: {}", e); break; } }, m = conn.recv() => match m { Some(data) => { if let Err(e) = stream.write_all(&data).await { log::debug!("error writing data to channel, ending: {}", e); break; } }, None => { log::debug!("EOF from channel, ending"); break; } } } } }); } } impl ServerTrait for Server { type Handler = ServerHandle; fn new_client(&mut self, _: Option) -> ServerHandle { ServerHandle::new() } } struct ServerHandle { authed_tx: Option>, authed_rx: Option>, cnx_tx: mpsc::UnboundedSender, cnx_rx: Option>, channel_senders: HashMap>>, } impl ServerHandle { pub fn new() -> Self { let (authed_tx, authed_rx) = oneshot::channel(); let (cnx_tx, cnx_rx) = mpsc::unbounded_channel(); Self { authed_rx: Some(authed_rx), authed_tx: Some(authed_tx), cnx_rx: Some(cnx_rx), cnx_tx, channel_senders: HashMap::new(), } } /// Takes the receiver from a newly-created handle. pub fn take_rx(&mut self) -> Option> { self.cnx_rx.take() } /// Takes the receiver from a newly-created handle. pub fn take_authed(&mut self) -> Option> { self.authed_rx.take() } } #[async_trait] impl russh::server::Handler for ServerHandle { type Error = russh::Error; async fn auth_succeeded( mut self, session: russh::server::Session, ) -> Result<(Self, russh::server::Session), Self::Error> { if let Some(tx) = self.authed_tx.take() { tx.send(()).ok(); } Ok((self, session)) } /// Connecting clients will use "none" auth on their channels. async fn auth_none(self, _: &str) -> Result<(Self, russh::server::Auth), Self::Error> { Ok((self, russh::server::Auth::Accept)) } async fn channel_open_forwarded_tcpip( mut self, channel: russh::Channel, _host_to_connect: &str, port_to_connect: u32, _originator_address: &str, _originator_port: u32, session: russh::server::Session, ) -> Result<(Self, bool, russh::server::Session), Self::Error> { let (sender, receiver) = mpsc::channel(10); let txd = self.cnx_tx.send(ForwardedPortConnection { port: port_to_connect, channel: channel.id(), handle: session.handle(), receiver, }); if txd.is_ok() { self.channel_senders.insert(channel.id(), sender); } Ok((self, true, session)) } async fn data( mut self, channel: russh::ChannelId, data: &[u8], session: russh::server::Session, ) -> Result<(Self, russh::server::Session), Self::Error> { let data_vec = data.to_vec(); if let Some(sender) = self.channel_senders.get(&channel) { if sender.send(data_vec).await.is_err() { self.channel_senders.remove(&channel); } } Ok((self, session)) } } /// Type sent from the Handler back to the processing queue. This can be a /// channel starting or stopping, or data on a channel. #[derive(Debug)] enum ChannelOp { Open(russh::ChannelId), Close(russh::ChannelId), Data(russh::ChannelId, Vec), } /// The Client implements the russh handler for the main SSH session on which /// connections will come in via channels. struct Client { sender: mpsc::UnboundedSender, } impl Client { pub fn new() -> (Self, mpsc::UnboundedReceiver) { let (tx, rx) = mpsc::unbounded_channel(); (Client { sender: tx }, rx) } } #[async_trait] impl russh::client::Handler for Client { type Error = russh::Error; async fn check_server_key( self, _server_public_key: &russh_keys::key::PublicKey, ) -> Result<(Self, bool), Self::Error> { Ok((self, true)) } fn server_channel_handle_unknown( &self, channel: russh::ChannelId, channel_type: &[u8], ) -> bool { if channel_type == b"client-ssh-session-stream" { self.sender.send(ChannelOp::Open(channel)).ok(); true } else { false } } async fn channel_close( self, channel: russh::ChannelId, session: russh::client::Session, ) -> Result<(Self, russh::client::Session), Self::Error> { self.sender.send(ChannelOp::Close(channel)).ok(); Ok((self, session)) } async fn data( self, channel: russh::ChannelId, data: &[u8], session: russh::client::Session, ) -> Result<(Self, russh::client::Session), Self::Error> { self.sender .send(ChannelOp::Data(channel, data.to_vec())) .ok(); Ok((self, session)) } } /// AsyncRead/AsyncWrite for converting SSH Channels into AsyncRead/AsyncWrite. struct AsyncRWChannel { id: russh::ChannelId, session: Arc>, incoming: mpsc::UnboundedReceiver>, readbuf: super::io::ReadBuffer, is_write_fut_valid: bool, write_fut: tokio_util::sync::ReusableBoxFuture<'static, Result<(), russh::CryptoVec>>, } impl AsyncRWChannel { pub fn new( id: russh::ChannelId, session: Arc>, ) -> (Self, mpsc::UnboundedSender>) { let (tx, rx) = mpsc::unbounded_channel(); ( AsyncRWChannel { id, session, incoming: rx, readbuf: super::io::ReadBuffer::default(), is_write_fut_valid: false, write_fut: tokio_util::sync::ReusableBoxFuture::new(make_client_write_fut(None)), }, tx, ) } } /// Makes a future that writes to the russh handle. This general approach was /// taken from https://docs.rs/tokio-util/0.7.3/tokio_util/sync/struct.PollSender.html /// This is just like make_server_write_fut, but for clients (they don't share a trait...) async fn make_client_write_fut( data: Option<( Arc>, russh::ChannelId, Vec, )>, ) -> Result<(), russh::CryptoVec> { match data { Some((client, id, data)) => client.data(id, CryptoVec::from(data)).await, None => unreachable!("this future should not be pollable in this state"), } } impl AsyncWrite for AsyncRWChannel { fn poll_write( mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &[u8], ) -> Poll> { if !self.is_write_fut_valid { let session = self.session.clone(); let id = self.id; self.write_fut .set(make_client_write_fut(Some((session, id, buf.to_vec())))); self.is_write_fut_valid = true; } self.poll_flush(cx).map(|r| r.map(|_| buf.len())) } fn poll_flush( mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> Poll> { if !self.is_write_fut_valid { return Poll::Ready(Ok(())); } match self.write_fut.poll(cx) { Poll::Pending => Poll::Pending, Poll::Ready(Ok(_)) => { self.is_write_fut_valid = false; Poll::Ready(Ok(())) } Poll::Ready(Err(_)) => { self.is_write_fut_valid = false; Poll::Ready(Err(io::Error::new(io::ErrorKind::Other, "EOF"))) } } } fn poll_shutdown( self: Pin<&mut Self>, _cx: &mut std::task::Context<'_>, ) -> Poll> { Poll::Ready(Ok(())) } } impl AsyncRead for AsyncRWChannel { fn poll_read( mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &mut tokio::io::ReadBuf<'_>, ) -> Poll> { if let Some((v, s)) = self.readbuf.take_data() { return self.readbuf.put_data(buf, v, s); } match self.incoming.poll_recv(cx) { Poll::Ready(Some(msg)) => self.readbuf.put_data(buf, msg, 0), Poll::Ready(None) => Poll::Ready(Err(io::Error::new(io::ErrorKind::Other, "EOF"))), Poll::Pending => Poll::Pending, } } } pub struct RelayHandle { endpoint: TunnelRelayTunnelEndpoint, session: Arc>, join: JoinHandle>, } impl RelayHandle { /// Gets the endpoint this relay is connected to. pub fn endpoint(&self) -> &TunnelRelayTunnelEndpoint { &self.endpoint } /// Closes the tunnel and waits for all associated tasks to end. pub async fn close(self) -> Result<(), TunnelError> { let result = self .session .disconnect(russh::Disconnect::ByApplication, "disconnect", "en") .await; self.join.await.ok(); result.map_err(TunnelError::TunnelRelayDisconnected) } } impl std::future::Future for RelayHandle { type Output = Result<(), TunnelError>; fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll { match std::future::Future::poll(Pin::new(&mut self.join), cx) { Poll::Ready(r) => Poll::Ready(match r { Ok(Ok(_)) => Ok(()), Ok(Err(e)) => Err(TunnelError::TunnelRelayDisconnected(e)), Err(_) => Ok(()), }), Poll::Pending => Poll::Pending, } } } dev-tunnels-0.0.25/rs/src/connections/ws.rs000066400000000000000000000323341450757157500206230ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. use std::{io, pin::Pin, task::Poll, time::Duration}; use futures::{Future, Sink, Stream}; use tokio::{ io::{AsyncRead, AsyncWrite}, net::TcpStream, time::{sleep, Instant, Sleep}, }; use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; use crate::management::{HttpError, ResponseError}; use super::errors::TunnelError; /// AsyncRead/AsyncWrite wrapper for a WebSocketStream. pub(crate) struct AsyncRWWebSocket { websocket: WebSocketStream, readbuf: super::io::ReadBuffer, ping_timer: Pin>, ping_state: PingState, ping_interval: Duration, ping_timeout: Duration, } enum PingState { WillPing, SendingPing, WaitingForPong, } pub(crate) struct AsyncRWWebSocketOptions { pub websocket: WebSocketStream, pub ping_interval: Duration, pub ping_timeout: Duration, } impl AsyncRWWebSocket where S: AsyncRead + AsyncWrite + Unpin, { pub fn new(opts: AsyncRWWebSocketOptions) -> Self { AsyncRWWebSocket { websocket: opts.websocket, readbuf: super::io::ReadBuffer::default(), ping_timer: Box::pin(sleep(opts.ping_interval)), ping_state: PingState::WillPing, ping_interval: opts.ping_interval, ping_timeout: opts.ping_timeout, } } fn get_ws(&mut self) -> Pin<&mut WebSocketStream> { Pin::new(&mut self.websocket) } fn poll_send_ping( &mut self, cx: &mut std::task::Context<'_>, ) -> Option>> { match self.get_ws().poll_flush(cx) { Poll::Ready(Ok(_)) => { let deadline = Instant::now() + self.ping_timeout; self.ping_timer.as_mut().reset(deadline); self.ping_state = PingState::WaitingForPong; log::debug!("sent liveness ping"); None } Poll::Ready(Err(e)) => Some(Poll::Ready(Err(tung_to_io_error(e)))), Poll::Pending => Some(Poll::Pending), } } } fn tung_to_io_error(e: tungstenite::Error) -> io::Error { match e { tungstenite::Error::Io(e) => e, _ => io::Error::new(io::ErrorKind::Other, e.to_string()), } } impl AsyncWrite for AsyncRWWebSocket where S: AsyncRead + AsyncWrite + Unpin, { fn poll_write( self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &[u8], ) -> Poll> { let sm = self.get_mut(); match sm.get_ws().poll_ready(cx) { Poll::Ready(Ok(())) => { sm.get_ws() .start_send(tungstenite::Message::Binary(buf.to_vec())) .map_err(tung_to_io_error)?; Poll::Ready(Ok(buf.len())) } Poll::Ready(Err(e)) => Poll::Ready(Err(tung_to_io_error(e))), Poll::Pending => Poll::Pending, } } fn poll_flush( self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> Poll> { self.get_mut() .get_ws() .poll_flush(cx) .map_err(tung_to_io_error) } fn poll_shutdown( self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> Poll> { self.get_mut() .get_ws() .poll_close(cx) .map_err(tung_to_io_error) } } impl AsyncRead for AsyncRWWebSocket where S: AsyncRead + AsyncWrite + Unpin, { fn poll_read( mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &mut tokio::io::ReadBuf<'_>, ) -> Poll> { if let Some((v, s)) = self.readbuf.take_data() { return self.readbuf.put_data(buf, v, s); } // The following blocks implement the state machine for liveness checks // via a websocket ping/pong. There is a "sleep" on the struct, which // is bumped every time we get a new message, along with a "state". // // - When sleep times out the first time (state=WillPing), we poll the // websocket for readiness, and then enqueue a ping message. // - When sending that ping (state=SendingPing), we poll_flush the socket // until that gets sent, reset the timer, and then move to WaitForPong. // - The next time the timer times out, if we're still state=WaitForPong // state (i.e. the state was not updated in the below read loop) then // we signal EOF to the caller. if let PingState::SendingPing = self.ping_state { if let Some(ret) = self.poll_send_ping(cx) { return ret; } } else if Pin::new(&mut self.ping_timer).poll(cx).is_ready() { match self.ping_state { PingState::WaitingForPong => { log::info!("websocket pong timed out, closing"); return Poll::Ready(Ok(())); } PingState::WillPing => match self.get_ws().poll_ready(cx) { Poll::Ready(Ok(_)) => { if let Err(e) = self.get_ws().start_send(tungstenite::Message::Ping(vec![])) { return Poll::Ready(Err(tung_to_io_error(e))); } self.ping_state = PingState::SendingPing; if let Some(ret) = self.poll_send_ping(cx) { return ret; } } Poll::Ready(Err(e)) => return Poll::Ready(Err(tung_to_io_error(e))), Poll::Pending => return Poll::Pending, }, PingState::SendingPing => unreachable!(), } } // That's the end of ping/pong. Now the standard read loop: loop { match self.get_ws().poll_next(cx) { Poll::Ready(Some(Ok(msg))) => { // bump the timeout to avoid unnecessary work if messages // are still flowing. let deadline = Instant::now() + self.ping_interval; self.ping_timer.as_mut().reset(deadline); match msg { tungstenite::Message::Text(text) => { return self.readbuf.put_data(buf, text.into_bytes(), 0); } tungstenite::Message::Binary(bin) => { return self.readbuf.put_data(buf, bin, 0); } tungstenite::Message::Close(_) => return Poll::Ready(Ok(())), tungstenite::Message::Pong(_) => { log::debug!("received liveness pong"); self.ping_state = PingState::WillPing; } // Note: tungstenite handles replying to pings internally, // so we don't need to handle that here. _ => { /* read next */ } } } Poll::Ready(Some(Err(e))) => { log::info!("error reading websocket: {}", e); return Poll::Ready(Err(tung_to_io_error(e))); } Poll::Ready(None) => return Poll::Ready(Ok(())), Poll::Pending => return Poll::Pending, } } } } pub(crate) async fn connect_directly( ws_req: tungstenite::handshake::client::Request, ) -> Result>, TunnelError> { let (ws, _) = connect_async(ws_req) .await .map_err(TunnelError::WebSocketError)?; Ok(ws) } pub(crate) async fn connect_via_proxy( ws_req: tungstenite::handshake::client::Request, proxy_addr: &str, ) -> Result>, TunnelError> { // format the remote authority, explicitly adding a port since it's // required (by some proxies) in CONNECT let authority = { let port = ws_req.uri().port_u16().unwrap_or(443); let hostname = ws_req.uri().host().expect("expected to have uri host"); format!("{}:{}", hostname, port) }; let stream = TcpStream::connect(proxy_addr) .await .map_err(TunnelError::ProxyConnectionFailed)?; let (mut request_sender, conn) = hyper::client::conn::handshake(stream) .await .map_err(TunnelError::ProxyHandshakeFailed)?; let conn = tokio::spawn(conn.without_shutdown()); let connect_req = hyper::Request::connect(&authority) .body(hyper::Body::empty()) .expect("expected to make connect request"); let res = request_sender .send_request(connect_req) .await .map_err(TunnelError::ProxyConnectRequestFailed)?; if !res.status().is_success() { return Err(TunnelError::HttpError { reason: "error sending tunnel CONNECT request", error: HttpError::ResponseError(ResponseError { url: reqwest::Url::parse(proxy_addr).unwrap(), status_code: res.status(), data: hyper::body::to_bytes(res.into_body()) .await .map(|b| String::from_utf8_lossy(&b).to_string()) .ok(), request_id: None, }), }); } let tcp = conn.await.unwrap().unwrap().io; let (ws_stream, _) = tokio_tungstenite::client_async_tls(ws_req, tcp).await?; Ok(ws_stream) } /// Creates a websocket request with additional headers. This is annoyingly /// complicated. https://github.com/snapview/tungstenite-rs/issues/107 pub(crate) fn build_websocket_request( url: &str, extra_headers: &[(&str, &str)], ) -> Result { let url = reqwest::Url::try_from(url).map_err(|e| TunnelError::InvalidHostEndpoint(e.to_string()))?; let host = url .host() .ok_or_else(|| TunnelError::InvalidHostEndpoint("missing host".to_string()))?; let mut req = tungstenite::handshake::client::Request::builder() .method("GET") .header("Host", host.to_string()) .header("Connection", "Upgrade") .header("Upgrade", "websocket") .header("Sec-WebSocket-Version", "13") .header( "Sec-WebSocket-Key", tungstenite::handshake::client::generate_key(), ); for (key, value) in extra_headers { req = req.header(*key, *value); } req.uri(url.as_str()) .body(()) .map_err(|e| TunnelError::InvalidHostEndpoint(e.to_string())) } #[cfg(test)] mod test { use std::time::Duration; use futures::{StreamExt, TryStreamExt}; use rand::RngCore; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::{TcpListener, TcpStream}, }; use tokio_tungstenite::connect_async; use super::{build_websocket_request, AsyncRWWebSocket, AsyncRWWebSocketOptions}; #[tokio::test] async fn test_websocket_stream() { let echo_server = TcpListener::bind("127.0.0.1:0") .await .expect("expect to listen"); let req = build_websocket_request( &format!("ws://{}", echo_server.local_addr().unwrap()), &[("User-Agent", "test client")], ) .expect("expected to make req"); tokio::spawn(async move { let (cnx, _) = echo_server.accept().await.expect("expect client"); accept_echo_server_connection(cnx).await; }); let input_len = 1024 * 1024; let mut input = Vec::with_capacity(input_len); for i in 0..input_len { input.push(i as u8); } let (cnx, _) = connect_async(req).await.expect("expected to connect"); let (mut read, mut write) = tokio::io::split(AsyncRWWebSocket::new(AsyncRWWebSocketOptions { ping_interval: Duration::from_secs(60), ping_timeout: Duration::from_secs(1), websocket: cnx, })); let input_dup = input.clone(); tokio::spawn(async move { let mut i = 0; while i < input_len { let next = std::cmp::min( input_len, i + (rand::thread_rng().next_u32() % 100000) as usize, ); write .write_all(&input_dup[i..next]) .await .expect("expected to write"); i = next; } }); let mut output = Vec::new(); output.resize(input_len, 0); read.read_exact(&mut output) .await .expect("expected to read"); assert_eq!(input, output); } async fn accept_echo_server_connection(stream: TcpStream) { let ws_stream = tokio_tungstenite::accept_async(stream) .await .expect("Error during the websocket handshake occurred"); let (write, read) = ws_stream.split(); // We should not forward messages other than text or binary. read.try_filter(|msg| futures::future::ready(msg.is_text() || msg.is_binary())) .forward(write) .await .ok(); } } dev-tunnels-0.0.25/rs/src/contracts/000077500000000000000000000000001450757157500172755ustar00rootroot00000000000000dev-tunnels-0.0.25/rs/src/contracts/cluster_details.rs000066400000000000000000000013721450757157500230340ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/ClusterDetails.cs use serde::{Deserialize, Serialize}; // Details of a tunneling service cluster. Each cluster represents an instance of the // tunneling service running in a particular Azure region. New tunnels are created in the // current region unless otherwise specified. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct ClusterDetails { // A cluster identifier based on its region. pub cluster_id: String, // The URI of the service cluster. pub uri: String, // The Azure location of the cluster. pub azure_location: String, } dev-tunnels-0.0.25/rs/src/contracts/error_codes.rs000066400000000000000000000007751450757157500221620ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/ErrorCodes.cs // Error codes for ErrorDetail.Code and `x-ms-error-code` header. // Operation timed out. pub const ERROR_CODES_TIMEOUT: &str = r#"Timeout"#; // Operation cannot be performed because the service is not available. pub const ERROR_CODES_SERVICE_UNAVAILABLE: &str = r#"ServiceUnavailable"#; // Internal error. pub const ERROR_CODES_INTERNAL_ERROR: &str = r#"InternalError"#; dev-tunnels-0.0.25/rs/src/contracts/error_detail.rs000066400000000000000000000020341450757157500223150ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/ErrorDetail.cs use crate::contracts::InnerErrorDetail; use serde::{Deserialize, Serialize}; // The top-level error object whose code matches the x-ms-error-code response header #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct ErrorDetail { // One of a server-defined set of error codes defined in `ErrorCodes`. pub code: String, // A human-readable representation of the error. pub message: String, // The target of the error. pub target: Option, // An array of details about specific errors that led to this reported error. #[serde(skip_serializing_if = "Vec::is_empty", default)] pub details: Vec, // An object containing more specific information than the current object about the // error. #[serde(rename = "innererror")] pub inner_error: Option, } dev-tunnels-0.0.25/rs/src/contracts/inner_error_detail.rs000066400000000000000000000014131450757157500235100ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/InnerErrorDetail.cs use serde::{Deserialize, Serialize}; // An object containing more specific information than the current object about the error. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct InnerErrorDetail { // A more specific error code than was provided by the containing error. One of a // server-defined set of error codes in `ErrorCodes`. pub code: String, // An object containing more specific information than the current object about the // error. #[serde(rename = "innererror")] pub inner_error: Option>, } dev-tunnels-0.0.25/rs/src/contracts/local_network_tunnel_endpoint.rs000066400000000000000000000024271450757157500260000ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/LocalNetworkTunnelEndpoint.cs use crate::contracts::TunnelEndpoint; use serde::{Deserialize, Serialize}; // Parameters for connecting to a tunnel via a local network connection. // // While a direct connection is technically not "tunneling", tunnel hosts may accept // connections via the local network as an optional more-efficient alternative to a relay. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct LocalNetworkTunnelEndpoint { #[serde(flatten)] pub base: TunnelEndpoint, // Gets or sets a list of IP endpoints where the host may accept connections. // // A host may accept connections on multiple IP endpoints simultaneously if there are // multiple network interfaces on the host system and/or if the host supports both // IPv4 and IPv6. Each item in the list is a URI consisting of a scheme (which gives // an indication of the network connection protocol), an IP address (IPv4 or IPv6) and // a port number. The URIs do not typically include any paths, because the connection // is not normally HTTP-based. pub host_endpoints: Vec, } dev-tunnels-0.0.25/rs/src/contracts/mod.rs000066400000000000000000000040621450757157500204240ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from RustContractWriter.cs mod cluster_details; mod error_codes; mod error_detail; mod inner_error_detail; mod local_network_tunnel_endpoint; mod named_rate_status; mod problem_details; mod rate_status; mod resource_status; mod service_version_details; mod tunnel; mod tunnel_access_control; mod tunnel_access_control_entry; mod tunnel_access_control_entry_type; mod tunnel_access_scopes; mod tunnel_access_subject; mod tunnel_authentication_schemes; mod tunnel_connection_mode; mod tunnel_constraints; mod tunnel_endpoint; mod tunnel_environments; mod tunnel_header_names; mod tunnel_list_by_region; mod tunnel_list_by_region_response; mod tunnel_list_response; mod tunnel_options; mod tunnel_port; mod tunnel_port_list_response; mod tunnel_port_status; mod tunnel_port_v2; mod tunnel_protocol; mod tunnel_relay_tunnel_endpoint; mod tunnel_service_properties; mod tunnel_status; mod tunnel_v2; pub use cluster_details::*; pub use error_codes::*; pub use error_detail::*; pub use inner_error_detail::*; pub use local_network_tunnel_endpoint::*; pub use named_rate_status::*; pub use problem_details::*; pub use rate_status::*; pub use resource_status::*; pub use service_version_details::*; pub use tunnel::*; pub use tunnel_access_control::*; pub use tunnel_access_control_entry::*; pub use tunnel_access_control_entry_type::*; pub use tunnel_access_scopes::*; pub use tunnel_access_subject::*; pub use tunnel_authentication_schemes::*; pub use tunnel_connection_mode::*; pub use tunnel_constraints::*; pub use tunnel_endpoint::*; pub use tunnel_environments::*; pub use tunnel_header_names::*; pub use tunnel_list_by_region::*; pub use tunnel_list_by_region_response::*; pub use tunnel_list_response::*; pub use tunnel_options::*; pub use tunnel_port::*; pub use tunnel_port_list_response::*; pub use tunnel_port_status::*; pub use tunnel_port_v2::*; pub use tunnel_protocol::*; pub use tunnel_relay_tunnel_endpoint::*; pub use tunnel_service_properties::*; pub use tunnel_status::*; pub use tunnel_v2::*; dev-tunnels-0.0.25/rs/src/contracts/named_rate_status.rs000066400000000000000000000007671450757157500233570ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/NamedRateStatus.cs use crate::contracts::RateStatus; use serde::{Deserialize, Serialize}; // A named `RateStatus`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct NamedRateStatus { #[serde(flatten)] pub base: RateStatus, // The name of the rate status. pub name: Option, } dev-tunnels-0.0.25/rs/src/contracts/problem_details.rs000066400000000000000000000020301450757157500230030ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/ProblemDetails.cs use serde::{Deserialize, Serialize}; use std::collections::HashMap; // Structure of error details returned by the tunnel service, including validation errors. // // This object may be returned with a response status code of 400 (or other 4xx code). It // is compatible with RFC 7807 Problem Details (https://tools.ietf.org/html/rfc7807) and // https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.problemdetails but // doesn't require adding a dependency on that package. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct ProblemDetails { // Gets or sets the error title. pub title: Option, // Gets or sets the error detail. pub detail: Option, // Gets or sets additional details about individual request properties. pub errors: Option>>, } dev-tunnels-0.0.25/rs/src/contracts/rate_status.rs000066400000000000000000000016351450757157500222060ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/RateStatus.cs use crate::contracts::ResourceStatus; use serde::{Deserialize, Serialize}; // Current value and limit information for a rate-limited operation related to a tunnel or // port. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct RateStatus { #[serde(flatten)] pub base: ResourceStatus, // Gets or sets the length of each period, in seconds, over which the rate is // measured. // // For rates that are limited by month (or billing period), this value may represent // an estimate, since the actual duration may vary by the calendar. pub period_seconds: Option, // Gets or sets the unix time in seconds when this status will be reset. pub reset_time: Option, } dev-tunnels-0.0.25/rs/src/contracts/resource_status.rs000066400000000000000000000024611450757157500231000ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/ResourceStatus.cs use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(untagged)] pub enum ResourceStatus { Detailed(DetailedResourceStatus), Count(u32), } impl ResourceStatus { pub fn get_count(&self) -> u64 { match self { ResourceStatus::Detailed(d) => d.current, ResourceStatus::Count(c) => (*c).into(), } } } // Current value and limit for a limited resource related to a tunnel or tunnel port. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct DetailedResourceStatus { // Gets or sets the current value. pub current: u64, // Gets or sets the limit enforced by the service, or null if there is no limit. // // Any requests that would cause the limit to be exceeded may be denied by the // service. For HTTP requests, the response is generally a 403 Forbidden status, with // details about the limit in the response body. pub limit: Option, // Gets or sets an optional source of the `ResourceStatus.Limit`, or null if there is // no limit. pub limit_source: Option, } dev-tunnels-0.0.25/rs/src/contracts/service_version_details.rs000066400000000000000000000016711450757157500245620ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/ServiceVersionDetails.cs use serde::{Deserialize, Serialize}; // Data contract for service version details. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct ServiceVersionDetails { // Gets or sets the version of the service. E.g. "1.0.6615.53976". The version // corresponds to the build number. pub version: Option, // Gets or sets the commit ID of the service. pub commit_id: Option, // Gets or sets the commit date of the service. pub commit_date: Option, // Gets or sets the cluster ID of the service that handled the request. pub cluster_id: Option, // Gets or sets the Azure location of the service that handled the request. pub azure_location: Option, } dev-tunnels-0.0.25/rs/src/contracts/tunnel.rs000066400000000000000000000060531450757157500211540ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/Tunnel.cs use chrono::{DateTime, Utc}; use crate::contracts::TunnelAccessControl; use crate::contracts::TunnelEndpoint; use crate::contracts::TunnelOptions; use crate::contracts::TunnelPort; use crate::contracts::TunnelStatus; use serde::{Deserialize, Serialize}; use std::collections::HashMap; // Data contract for tunnel objects managed through the tunnel service REST API. #[derive(Clone, Debug, Deserialize, Serialize, Default)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct Tunnel { // Gets or sets the ID of the cluster the tunnel was created in. pub cluster_id: Option, // Gets or sets the generated ID of the tunnel, unique within the cluster. pub tunnel_id: Option, // Gets or sets the optional short name (alias) of the tunnel. // // The name must be globally unique within the parent domain, and must be a valid // subdomain. pub name: Option, // Gets or sets the description of the tunnel. pub description: Option, // Gets or sets the tags of the tunnel. #[serde(skip_serializing_if = "Vec::is_empty", default)] pub tags: Vec, // Gets or sets the optional parent domain of the tunnel, if it is not using the // default parent domain. pub domain: Option, // Gets or sets a dictionary mapping from scopes to tunnel access tokens. pub access_tokens: Option>, // Gets or sets access control settings for the tunnel. // // See `TunnelAccessControl` documentation for details about the access control model. pub access_control: Option, // Gets or sets default options for the tunnel. pub options: Option, // Gets or sets current connection status of the tunnel. pub status: Option, // Gets or sets an array of endpoints where hosts are currently accepting client // connections to the tunnel. #[serde(skip_serializing_if = "Vec::is_empty", default)] pub endpoints: Vec, // Gets or sets a list of ports in the tunnel. // // This optional property enables getting info about all ports in a tunnel at the same // time as getting tunnel info, or creating one or more ports at the same time as // creating a tunnel. It is omitted when listing (multiple) tunnels, or when updating // tunnel properties. (For the latter, use APIs to create/update/delete individual // ports instead.) #[serde(skip_serializing_if = "Vec::is_empty", default)] pub ports: Vec, // Gets or sets the time in UTC of tunnel creation. pub created: Option>, // Gets or the time the tunnel will be deleted if it is not used or updated. pub expiration: Option>, // Gets or the custom amount of time the tunnel will be valid if it is not used or // updated in seconds. pub custom_expiration: Option, } dev-tunnels-0.0.25/rs/src/contracts/tunnel_access_control.rs000066400000000000000000000027021450757157500242320ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelAccessControl.cs use crate::contracts::TunnelAccessControlEntry; use serde::{Deserialize, Serialize}; // Data contract for access control on a `Tunnel` or `TunnelPort`. // // Tunnels and tunnel ports can each optionally have an access-control property set on // them. An access-control object contains a list (ACL) of entries (ACEs) that specify the // access scopes granted or denied to some subjects. Tunnel ports inherit the ACL from the // tunnel, though ports may include ACEs that augment or override the inherited rules. // Currently there is no capability to define "roles" for tunnel access (where a role // specifies a set of related access scopes), and assign roles to users. That feature may // be added in the future. (It should be represented as a separate `RoleAssignments` // property on this class.) #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct TunnelAccessControl { // Gets or sets the list of access control entries. // // The order of entries is significant: later entries override earlier entries that // apply to the same subject. However, deny rules are always processed after allow // rules, therefore an allow rule cannot override a deny rule for the same subject. pub entries: Vec, } dev-tunnels-0.0.25/rs/src/contracts/tunnel_access_control_entry.rs000066400000000000000000000114031450757157500254510ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelAccessControlEntry.cs use chrono::{DateTime, Utc}; use crate::contracts::TunnelAccessControlEntryType; use serde::{Deserialize, Serialize}; // Data contract for an access control entry on a `Tunnel` or `TunnelPort`. // // An access control entry (ACE) grants or denies one or more access scopes to one or more // subjects. Tunnel ports inherit access control entries from their tunnel, and they may // have additional port-specific entries that augment or override those access rules. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct TunnelAccessControlEntry { // Gets or sets the access control entry type. #[serde(rename = "type")] pub kind: TunnelAccessControlEntryType, // Gets or sets the provider of the subjects in this access control entry. The // provider impacts how the subject identifiers are resolved and displayed. The // provider may be an identity provider such as AAD, or a system or standard such as // "ssh" or "ipv4". // // For user, group, or org ACEs, this value is the name of the identity provider of // the user/group/org IDs. It may be one of the well-known provider names in // `TunnelAccessControlEntry.Providers`, or (in the future) a custom identity // provider. For public key ACEs, this value is the type of public key, e.g. "ssh". // For IP address range ACEs, this value is the IP address version, "ipv4" or "ipv6", // or "service-tag" if the range is defined by an Azure service tag. For anonymous // ACEs, this value is null. pub provider: Option, // Gets or sets a value indicating whether this is an access control entry on a tunnel // port that is inherited from the tunnel's access control list. #[serde(default)] pub is_inherited: bool, // Gets or sets a value indicating whether this entry is a deny rule that blocks // access to the specified users. Otherwise it is an allow rule. // // All deny rules (including inherited rules) are processed after all allow rules. // Therefore a deny ACE cannot be overridden by an allow ACE that is later in the list // or on a more-specific resource. In other words, inherited deny ACEs cannot be // overridden. #[serde(default)] pub is_deny: bool, // Gets or sets a value indicating whether this entry applies to all subjects that are // NOT in the `TunnelAccessControlEntry.Subjects` list. // // Examples: an inverse organizations ACE applies to all users who are not members of // the listed organization(s); an inverse anonymous ACE applies to all authenticated // users; an inverse IP address ranges ACE applies to all clients that are not within // any of the listed IP address ranges. The inverse option is often useful in policies // in combination with `TunnelAccessControlEntry.IsDeny`, for example a policy could // deny access to users who are not members of an organization or are outside of an IP // address range, effectively blocking any tunnels from allowing outside access // (because inherited deny ACEs cannot be overridden). #[serde(default)] pub is_inverse: bool, // Gets or sets an optional organization context for all subjects of this entry. The // use and meaning of this value depends on the `TunnelAccessControlEntry.Type` and // `TunnelAccessControlEntry.Provider` of this entry. // // For AAD users and group ACEs, this value is the AAD tenant ID. It is not currently // used with any other types of ACEs. pub organization: Option, // Gets or sets the subjects for the entry, such as user or group IDs. The format of // the values depends on the `TunnelAccessControlEntry.Type` and // `TunnelAccessControlEntry.Provider` of this entry. pub subjects: Vec, // Gets or sets the access scopes that this entry grants or denies to the subjects. // // These must be one or more values from `TunnelAccessScopes`. pub scopes: Vec, // Gets or sets the expiration for an access control entry. // // If no value is set then this value is null. pub expiration: Option>, } // Constants for well-known identity providers. // Microsoft (AAD) identity provider. pub const PROVIDERS_MICROSOFT: &str = r#"microsoft"#; // GitHub identity provider. pub const PROVIDERS_GITHUB: &str = r#"github"#; // SSH public keys. pub const PROVIDERS_SSH: &str = r#"ssh"#; // IPv4 addresses. pub const PROVIDERS_IPV4: &str = r#"ipv4"#; // IPv6 addresses. pub const PROVIDERS_IPV6: &str = r#"ipv6"#; // Service tags. pub const PROVIDERS_SERVICE_TAG: &str = r#"service-tag"#; dev-tunnels-0.0.25/rs/src/contracts/tunnel_access_control_entry_type.rs000066400000000000000000000043651450757157500265230ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelAccessControlEntryType.cs use serde::{Deserialize, Serialize}; use std::fmt; // Specifies the type of `TunnelAccessControlEntry`. #[derive(Clone, Debug, Deserialize, Serialize)] pub enum TunnelAccessControlEntryType { // Uninitialized access control entry type. None, // The access control entry refers to all anonymous users. Anonymous, // The access control entry is a list of user IDs that are allowed (or denied) access. Users, // The access control entry is a list of groups IDs that are allowed (or denied) // access. Groups, // The access control entry is a list of organization IDs that are allowed (or denied) // access. // // All users in the organizations are allowed (or denied) access, unless overridden by // following group or user rules. Organizations, // The access control entry is a list of repositories. Users are allowed access to the // tunnel if they have access to the repo. Repositories, // The access control entry is a list of public keys. Users are allowed access if they // can authenticate using a private key corresponding to one of the public keys. PublicKeys, // The access control entry is a list of IP address ranges that are allowed (or // denied) access to the tunnel. Ranges can be IPv4, IPv6, or Azure service tags. IPAddressRanges, } impl fmt::Display for TunnelAccessControlEntryType { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self { TunnelAccessControlEntryType::None => write!(f, "None"), TunnelAccessControlEntryType::Anonymous => write!(f, "Anonymous"), TunnelAccessControlEntryType::Users => write!(f, "Users"), TunnelAccessControlEntryType::Groups => write!(f, "Groups"), TunnelAccessControlEntryType::Organizations => write!(f, "Organizations"), TunnelAccessControlEntryType::Repositories => write!(f, "Repositories"), TunnelAccessControlEntryType::PublicKeys => write!(f, "PublicKeys"), TunnelAccessControlEntryType::IPAddressRanges => write!(f, "IPAddressRanges"), } } } dev-tunnels-0.0.25/rs/src/contracts/tunnel_access_scopes.rs000066400000000000000000000027501450757157500240510ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelAccessScopes.cs // Defines scopes for tunnel access tokens. // // A tunnel access token with one or more of these scopes typically also has cluster ID // and tunnel ID claims that limit the access scope to a specific tunnel, and may also // have one or more port claims that further limit the access to particular ports of the // tunnel. // Allows creating tunnels. This scope is valid only in policies at the global, domain, or // organization level; it is not relevant to an already-created tunnel or tunnel port. // (Creation of ports requires "manage" or "host" access to the tunnel.) pub const TUNNEL_ACCESS_SCOPES_CREATE: &str = r#"create"#; // Allows management operations on tunnels and tunnel ports. pub const TUNNEL_ACCESS_SCOPES_MANAGE: &str = r#"manage"#; // Allows management operations on all ports of a tunnel, but does not allow updating any // other tunnel properties or deleting the tunnel. pub const TUNNEL_ACCESS_SCOPES_MANAGE_PORTS: &str = r#"manage:ports"#; // Allows accepting connections on tunnels as a host. Includes access to update tunnel // endpoints and ports. pub const TUNNEL_ACCESS_SCOPES_HOST: &str = r#"host"#; // Allows inspecting tunnel connection activity and data. pub const TUNNEL_ACCESS_SCOPES_INSPECT: &str = r#"inspect"#; // Allows connecting to tunnels or ports as a client. pub const TUNNEL_ACCESS_SCOPES_CONNECT: &str = r#"connect"#; dev-tunnels-0.0.25/rs/src/contracts/tunnel_access_subject.rs000066400000000000000000000034451450757157500242160ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelAccessSubject.cs use crate::contracts::TunnelAccessControlEntryType; use serde::{Deserialize, Serialize}; // Properties about a subject of a tunnel access control entry (ACE), used when resolving // subject names to IDs when creating new ACEs, or formatting subject IDs to names when // displaying existing ACEs. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct TunnelAccessSubject { // Gets or sets the type of subject, e.g. user, group, or organization. #[serde(rename = "type")] pub kind: TunnelAccessControlEntryType, // Gets or sets the subject ID. // // The ID is typically a guid or integer that is unique within the scope of the // identity provider or organization, and never changes for that subject. pub id: Option, // Gets or sets the subject organization ID, which may be required if an organization // is not implied by the authentication context. pub organization_id: Option, // Gets or sets the partial or full subject name. // // When resolving a subject name to ID, a partial name may be provided, and the full // name is returned if the partial name was successfully resolved. When formatting a // subject ID to name, the full name is returned if the ID was found. pub name: Option, // Gets or sets an array of possible subject matches, if a partial name was provided // and did not resolve to a single subject. // // This property applies only when resolving subject names to IDs. #[serde(skip_serializing_if = "Vec::is_empty", default)] pub matches: Vec, } dev-tunnels-0.0.25/rs/src/contracts/tunnel_authentication_schemes.rs000066400000000000000000000013531450757157500257600ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelAuthenticationSchemes.cs // Defines string constants for authentication schemes supported by tunnel service APIs. // Authentication scheme for AAD (or Microsoft account) access tokens. pub const TUNNEL_AUTHENTICATION_SCHEMES_AAD: &str = r#"aad"#; // Authentication scheme for GitHub access tokens. pub const TUNNEL_AUTHENTICATION_SCHEMES_GITHUB: &str = r#"github"#; // Authentication scheme for tunnel access tokens. pub const TUNNEL_AUTHENTICATION_SCHEMES_TUNNEL: &str = r#"tunnel"#; // Authentication scheme for tunnelPlan access tokens. pub const TUNNEL_AUTHENTICATION_SCHEMES_TUNNEL_PLAN: &str = r#"tunnelplan"#; dev-tunnels-0.0.25/rs/src/contracts/tunnel_connection_mode.rs000066400000000000000000000021031450757157500243670ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelConnectionMode.cs use serde::{Deserialize, Serialize}; use std::fmt; // Specifies the connection protocol / implementation for a tunnel. // // Depending on the connection mode, hosts or clients might need to use different // authentication and connection protocols. #[derive(Clone, Debug, Deserialize, Serialize)] pub enum TunnelConnectionMode { // Connect directly to the host over the local network. // // While it's technically not "tunneling", this mode may be combined with others to // enable choosing the most efficient connection mode available. LocalNetwork, // Use the tunnel service's integrated relay function. TunnelRelay, } impl fmt::Display for TunnelConnectionMode { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self { TunnelConnectionMode::LocalNetwork => write!(f, "LocalNetwork"), TunnelConnectionMode::TunnelRelay => write!(f, "TunnelRelay"), } } } dev-tunnels-0.0.25/rs/src/contracts/tunnel_constraints.rs000066400000000000000000000124741450757157500236070ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelConstraints.cs // Tunnel constraints. // Min length of tunnel cluster ID. pub const CLUSTER_ID_MIN_LENGTH: i32 = 3; // Max length of tunnel cluster ID. pub const CLUSTER_ID_MAX_LENGTH: i32 = 12; // Length of V1 tunnel id. pub const OLD_TUNNEL_ID_LENGTH: i32 = 8; // Min length of V2 tunnelId. pub const NEW_TUNNEL_ID_MIN_LENGTH: i32 = 3; // Max length of V2 tunnelId. pub const NEW_TUNNEL_ID_MAX_LENGTH: i32 = 60; // Length of a tunnel alias. pub const TUNNEL_ALIAS_LENGTH: i32 = 8; // Min length of tunnel name. pub const TUNNEL_NAME_MIN_LENGTH: i32 = 3; // Max length of tunnel name. pub const TUNNEL_NAME_MAX_LENGTH: i32 = 60; // Max length of tunnel or port description. pub const DESCRIPTION_MAX_LENGTH: i32 = 400; // Min length of a single tunnel or port tag. pub const TAG_MIN_LENGTH: i32 = 1; // Max length of a single tunnel or port tag. pub const TAG_MAX_LENGTH: i32 = 50; // Maximum number of tags that can be applied to a tunnel or port. pub const MAX_TAGS: i32 = 100; // Min length of a tunnel domain. pub const TUNNEL_DOMAIN_MIN_LENGTH: i32 = 4; // Max length of a tunnel domain. pub const TUNNEL_DOMAIN_MAX_LENGTH: i32 = 180; // Maximum number of items allowed in the tunnel ports array. The actual limit on number // of ports that can be created may be much lower, and may depend on various resource // limitations or policies. pub const TUNNEL_MAX_PORTS: i32 = 1000; // Maximum number of access control entries (ACEs) in a tunnel or tunnel port access // control list (ACL). pub const ACCESS_CONTROL_MAX_ENTRIES: i32 = 40; // Maximum number of subjects (such as user IDs) in a tunnel or tunnel port access control // entry (ACE). pub const ACCESS_CONTROL_MAX_SUBJECTS: i32 = 100; // Max length of an access control subject or organization ID. pub const ACCESS_CONTROL_SUBJECT_MAX_LENGTH: i32 = 200; // Max length of an access control subject name, when resolving names to IDs. pub const ACCESS_CONTROL_SUBJECT_NAME_MAX_LENGTH: i32 = 200; // Maximum number of scopes in an access control entry. pub const ACCESS_CONTROL_MAX_SCOPES: i32 = 10; // Regular expression that can match or validate tunnel cluster ID strings. // // Cluster IDs are alphanumeric; hyphens are not permitted. pub const CLUSTER_ID_PATTERN: &str = r#"^(([a-z]{3,4}[0-9]{1,3})|asse|aue|brs|euw|use)$"#; // Characters that are valid in tunnel IDs. Includes numbers and lowercase letters, // excluding vowels and 'y' (to avoid accidentally generating any random words). pub const OLD_TUNNEL_ID_CHARS: &str = r#"0123456789bcdfghjklmnpqrstvwxz"#; // Regular expression that can match or validate tunnel ID strings. // // Tunnel IDs are fixed-length and have a limited character set of numbers and lowercase // letters (minus vowels and y). pub const OLD_TUNNEL_ID_PATTERN: &str = r#"[0123456789bcdfghjklmnpqrstvwxz]{8}"#; // Characters that are valid in tunnel IDs. Includes numbers and lowercase letters, // excluding vowels and 'y' (to avoid accidentally generating any random words). pub const NEW_TUNNEL_ID_CHARS: &str = r#"0123456789abcdefghijklmnopqrstuvwxyz-"#; // Regular expression that can match or validate tunnel ID strings. // // Tunnel IDs are fixed-length and have a limited character set of numbers and lowercase // letters (minus vowels and y). pub const NEW_TUNNEL_ID_PATTERN: &str = r#"[a-z0-9][a-z0-9-]{1,58}[a-z0-9]"#; // Characters that are valid in tunnel IDs. Includes numbers and lowercase letters, // excluding vowels and 'y' (to avoid accidentally generating any random words). pub const TUNNEL_ALIAS_CHARS: &str = r#"0123456789bcdfghjklmnpqrstvwxz"#; // Regular expression that can match or validate tunnel alias strings. // // Tunnel Aliases are fixed-length and have a limited character set of numbers and // lowercase letters (minus vowels and y). pub const TUNNEL_ALIAS_PATTERN: &str = r#"[0123456789bcdfghjklmnpqrstvwxz]{3,60}"#; // Regular expression that can match or validate tunnel names. // // Tunnel names are alphanumeric and may contain hyphens. The pattern also allows an empty // string because tunnels may be unnamed. pub const TUNNEL_NAME_PATTERN: &str = r#"([a-z0-9][a-z0-9-]{1,58}[a-z0-9])|(^$)"#; // Regular expression that can match or validate tunnel or port tags. pub const TAG_PATTERN: &str = r#"[\w-=]{1,50}"#; // Regular expression that can match or validate tunnel domains. // // The tunnel service may perform additional contextual validation at the time the domain // is registered. pub const TUNNEL_DOMAIN_PATTERN: &str = r#"[0-9a-z][0-9a-z-.]{1,158}[0-9a-z]|(^$)"#; // Regular expression that can match or validate an access control subject or organization // ID. // // The : and / characters are allowed because subjects may include IP addresses and // ranges. The @ character is allowed because MSA subjects may be identified by email // address. pub const ACCESS_CONTROL_SUBJECT_PATTERN: &str = r#"[0-9a-zA-Z-._:/@]{0,200}"#; // Regular expression that can match or validate an access control subject name, when // resolving subject names to IDs. // // Note angle-brackets are only allowed when they wrap an email address as part of a // formatted name with email. The service will block any other use of angle-brackets, to // avoid any XSS risks. pub const ACCESS_CONTROL_SUBJECT_NAME_PATTERN: &str = r#"[ \w\d-.,/'"_@()<>]{0,200}"#; dev-tunnels-0.0.25/rs/src/contracts/tunnel_endpoint.rs000066400000000000000000000060621450757157500230540ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelEndpoint.cs use crate::contracts::TunnelConnectionMode; use serde::{Deserialize, Serialize}; // Base class for tunnel connection parameters. // // A tunnel endpoint specifies how and where hosts and clients can connect to a tunnel. // There is a subclass for each connection mode, each having different connection // parameters. A tunnel may have multiple endpoints for one host (or multiple hosts), and // clients can select their preferred endpoint(s) from those depending on network // environment or client capabilities. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct TunnelEndpoint { // Gets or sets the ID of this endpoint. pub id: Option, // Gets or sets the connection mode of the endpoint. // // This property is required when creating or updating an endpoint. The subclass type // is also an indication of the connection mode, but this property is necessary to // determine the subclass type when deserializing. pub connection_mode: TunnelConnectionMode, // Gets or sets the ID of the host that is listening on this endpoint. // // This property is required when creating or updating an endpoint. If the host // supports multiple connection modes, the host's ID is the same for all the endpoints // it supports. However different hosts may simultaneously accept connections at // different endpoints for the same tunnel, if enabled in tunnel options. pub host_id: String, // Gets or sets an array of public keys, which can be used by clients to authenticate // the host. #[serde(skip_serializing_if = "Vec::is_empty", default)] pub host_public_keys: Vec, // Gets or sets a string used to format URIs where a web client can connect to ports // of the tunnel. The string includes a `TunnelEndpoint.PortToken` that must be // replaced with the actual port number. pub port_uri_format: Option, // Gets or sets the URI where a web client can connect to the default port of the // tunnel. pub tunnel_uri: Option, // Gets or sets a string used to format ssh command where ssh client can connect to // shared ssh port of the tunnel. The string includes a `TunnelEndpoint.PortToken` // that must be replaced with the actual port number. pub port_ssh_command_format: Option, // Gets or sets the Ssh command where the Ssh client can connect to the default ssh // port of the tunnel. pub tunnel_ssh_command: Option, // Gets or sets the Ssh gateway public key which should be added to the // authorized_keys file so that tunnel service can connect to the shared ssh server. pub ssh_gateway_public_key: Option, } // Token included in `TunnelEndpoint.PortUriFormat` and // `TunnelEndpoint.PortSshCommandFormat` that is to be replaced by a specified port // number. pub const PORT_TOKEN: &str = "{port}"; dev-tunnels-0.0.25/rs/src/contracts/tunnel_environments.rs000066400000000000000000000022321450757157500237560ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. use crate::contracts::tunnel_service_properties::*; pub fn env_production() -> TunnelServiceProperties { TunnelServiceProperties { service_uri: format!("https://{}", PROD_DNS_NAME), service_app_id: PROD_FIRST_PARTY_APP_ID.to_owned(), service_internal_app_id: PROD_THIRD_PARTY_APP_ID.to_owned(), github_app_client_id: PROD_GITHUB_APP_CLIENT_ID.to_owned(), } } pub fn env_staging() -> TunnelServiceProperties { TunnelServiceProperties { service_uri: format!("https://{}", PPE_DNS_NAME), service_app_id: PROD_FIRST_PARTY_APP_ID.to_owned(), service_internal_app_id: PPE_THIRD_PARTY_APP_ID.to_owned(), github_app_client_id: NON_PROD_GITHUB_APP_CLIENT_ID.to_owned(), } } pub fn env_development() -> TunnelServiceProperties { TunnelServiceProperties { service_uri: format!("https://{}", DEV_DNS_NAME), service_app_id: NON_PROD_FIRST_PARTY_APP_ID.to_owned(), service_internal_app_id: DEV_THIRD_PARTY_APP_ID.to_owned(), github_app_client_id: NON_PROD_GITHUB_APP_CLIENT_ID.to_owned(), } } dev-tunnels-0.0.25/rs/src/contracts/tunnel_header_names.rs000066400000000000000000000020721450757157500236440ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelHeaderNames.cs // Header names for http requests that Tunnel Service can handle // Additional authorization header that can be passed to tunnel web forwarding to // authenticate and authorize the client. The format of the value is the same as // Authorization header that is sent to the Tunnel service by the tunnel SDK. Supported // schemes: "tunnel" with the tunnel access JWT good for 'Connect' scope. pub const X_TUNNEL_AUTHORIZATION: &str = r#"X-Tunnel-Authorization"#; // Request ID header that nginx ingress controller adds to all requests if it's not there. pub const X_REQUEST_ID: &str = r#"X-Request-ID"#; // Github Ssh public key which can be used to validate if it belongs to tunnel's owner. pub const X_GITHUB_SSH_KEY: &str = r#"X-Github-Ssh-Key"#; // Header that will skip the antiphishing page when connection to a tunnel through web // forwarding. pub const X_TUNNEL_SKIP_ANTIPHISHING_PAGE: &str = r#"X-Tunnel-Skip-AntiPhishing-Page"#; dev-tunnels-0.0.25/rs/src/contracts/tunnel_list_by_region.rs000066400000000000000000000014171450757157500242430ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelListByRegion.cs use crate::contracts::ErrorDetail; use crate::contracts::TunnelV2; use serde::{Deserialize, Serialize}; // Tunnel list by region. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct TunnelListByRegion { // Azure region name. pub region_name: Option, // Cluster id in the region. pub cluster_id: Option, // List of tunnels. #[serde(skip_serializing_if = "Vec::is_empty", default)] pub value: Vec, // Error detail if getting list of tunnels in the region failed. pub error: Option, } dev-tunnels-0.0.25/rs/src/contracts/tunnel_list_by_region_response.rs000066400000000000000000000012211450757157500261520ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelListByRegionResponse.cs use crate::contracts::TunnelListByRegion; use serde::{Deserialize, Serialize}; // Data contract for response of a list tunnel by region call. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct TunnelListByRegionResponse { // List of tunnels #[serde(skip_serializing_if = "Vec::is_empty", default)] pub value: Vec, // Link to get next page of results. pub next_link: Option, } dev-tunnels-0.0.25/rs/src/contracts/tunnel_list_response.rs000066400000000000000000000010451450757157500241210ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelListResponse.cs use crate::contracts::TunnelV2; use serde::{Deserialize, Serialize}; // Data contract for response of a list tunnel call. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct TunnelListResponse { // List of tunnels pub value: Vec, // Link to get next page of results pub next_link: Option, } dev-tunnels-0.0.25/rs/src/contracts/tunnel_options.rs000066400000000000000000000055351450757157500227330ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelOptions.cs use serde::{Deserialize, Serialize}; // Data contract for `Tunnel` or `TunnelPort` options. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct TunnelOptions { // Gets or sets a value indicating whether web-forwarding of this tunnel can run on // any cluster (region) without redirecting to the home cluster. This is only // applicable if the tunnel has a name and web-forwarding uses it. #[serde(default)] pub is_globally_available: bool, // Gets or sets a value for `Host` header rewriting to use in web-forwarding of this // tunnel or port. By default, with this property null or empty, web-forwarding uses // "localhost" to rewrite the header. Web-fowarding will use this property instead if // it is not null or empty. Port-level option, if set, takes precedence over this // option on the tunnel level. The option is ignored if IsHostHeaderUnchanged is true. #[serde(default)] pub host_header: Option, // Gets or sets a value indicating whether `Host` header is rewritten or the header // value stays intact. By default, if false, web-forwarding rewrites the host header // with the value from HostHeader property or "localhost". If true, the host header // will be whatever the tunnel's web-forwarding host is, e.g. // tunnel-name-8080.devtunnels.ms. Port-level option, if set, takes precedence over // this option on the tunnel level. #[serde(default)] pub is_host_header_unchanged: bool, // Gets or sets a value for `Origin` header rewriting to use in web-forwarding of this // tunnel or port. By default, with this property null or empty, web-forwarding uses // "http(s)://localhost" to rewrite the header. Web-fowarding will use this property // instead if it is not null or empty. Port-level option, if set, takes precedence // over this option on the tunnel level. The option is ignored if // IsOriginHeaderUnchanged is true. #[serde(default)] pub origin_header: Option, // Gets or sets a value indicating whether `Origin` header is rewritten or the header // value stays intact. By default, if false, web-forwarding rewrites the origin header // with the value from OriginHeader property or "http(s)://localhost". If true, the // Origin header will be whatever the tunnel's web-forwarding Origin is, e.g. // https://tunnel-name-8080.devtunnels.ms. Port-level option, if set, takes precedence // over this option on the tunnel level. #[serde(default)] pub is_origin_header_unchanged: bool, // Gets or sets if inspection is enabled for the tunnel. #[serde(default)] pub is_inspection_enabled: bool, } dev-tunnels-0.0.25/rs/src/contracts/tunnel_port.rs000066400000000000000000000064431450757157500222230ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelPort.cs use crate::contracts::TunnelAccessControl; use crate::contracts::TunnelOptions; use crate::contracts::TunnelPortStatus; use serde::{Deserialize, Serialize}; use std::collections::HashMap; // Data contract for tunnel port objects managed through the tunnel service REST API. #[derive(Clone, Debug, Deserialize, Serialize, Default)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct TunnelPort { // Gets or sets the ID of the cluster the tunnel was created in. pub cluster_id: Option, // Gets or sets the generated ID of the tunnel, unique within the cluster. pub tunnel_id: Option, // Gets or sets the IP port number of the tunnel port. pub port_number: u16, // Gets or sets the optional short name of the port. // // The name must be unique among named ports of the same tunnel. pub name: Option, // Gets or sets the optional description of the port. pub description: Option, // Gets or sets the tags of the port. #[serde(skip_serializing_if = "Vec::is_empty", default)] pub tags: Vec, // Gets or sets the protocol of the tunnel port. // // Should be one of the string constants from `TunnelProtocol`. pub protocol: Option, // Gets or sets a value indicating whether this port is a default port for the tunnel. // // A client that connects to a tunnel (by ID or name) without specifying a port number // will connect to the default port for the tunnel, if a default is configured. Or if // the tunnel has only one port then the single port is the implicit default. // // Selection of a default port for a connection also depends on matching the // connection to the port `TunnelPort.Protocol`, so it is possible to configure // separate defaults for distinct protocols like `TunnelProtocol.Http` and // `TunnelProtocol.Ssh`. #[serde(default)] pub is_default: bool, // Gets or sets a dictionary mapping from scopes to tunnel access tokens. // // Unlike the tokens in `Tunnel.AccessTokens`, these tokens are restricted to the // individual port. pub access_tokens: Option>, // Gets or sets access control settings for the tunnel port. // // See `TunnelAccessControl` documentation for details about the access control model. pub access_control: Option, // Gets or sets options for the tunnel port. pub options: Option, // Gets or sets current connection status of the tunnel port. pub status: Option, // Gets or sets the username for the ssh service user is trying to forward. // // Should be provided if the `TunnelProtocol` is Ssh. pub ssh_user: Option, // Gets or sets web forwarding URIs. If set, it's a list of absolute URIs where the // port can be accessed with web forwarding. #[serde(skip_serializing_if = "Vec::is_empty", default)] pub port_forwarding_uris: Vec, // Gets or sets inspection URI. If set, it's an absolute URIs where the port's traffic // can be inspected. pub inspection_uri: Option, } dev-tunnels-0.0.25/rs/src/contracts/tunnel_port_list_response.rs000066400000000000000000000010731450757157500251660ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelPortListResponse.cs use crate::contracts::TunnelPortV2; use serde::{Deserialize, Serialize}; // Data contract for response of a list tunnel ports call. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct TunnelPortListResponse { // List of tunnels pub value: Vec, // Link to get next page of results pub next_link: Option, } dev-tunnels-0.0.25/rs/src/contracts/tunnel_port_status.rs000066400000000000000000000036301450757157500236210ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelPortStatus.cs use crate::contracts::RateStatus; use crate::contracts::ResourceStatus; use serde::{Deserialize, Serialize}; // Data contract for `TunnelPort` status. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct TunnelPortStatus { // Gets or sets the current value and limit for the number of clients connected to the // port. // // This client connection count does not include non-port-specific connections such as // SDK and SSH clients. See `TunnelStatus.ClientConnectionCount` for status of those // connections. This count also does not include HTTP client connections, unless they // are upgraded to websockets. HTTP connections are counted per-request rather than // per-connection: see `TunnelPortStatus.HttpRequestRate`. pub client_connection_count: Option, // Gets or sets the UTC date time when a client was last connected to the port, or // null if a client has never connected. pub last_client_connection_time: Option, // Gets or sets the current value and limit for the rate of client connections to the // tunnel port. // // This client connection rate does not count non-port-specific connections such as // SDK and SSH clients. See `TunnelStatus.ClientConnectionRate` for those connection // types. This also does not include HTTP connections, unless they are upgraded to // websockets. HTTP connections are counted per-request rather than per-connection: // see `TunnelPortStatus.HttpRequestRate`. pub client_connection_rate: Option, // Gets or sets the current value and limit for the rate of HTTP requests to the // tunnel port. pub http_request_rate: Option, } dev-tunnels-0.0.25/rs/src/contracts/tunnel_port_v2.rs000066400000000000000000000064421450757157500226310ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelPortV2.cs use crate::contracts::TunnelAccessControl; use crate::contracts::TunnelOptions; use crate::contracts::TunnelPortStatus; use serde::{Deserialize, Serialize}; use std::collections::HashMap; // Data contract for tunnel port objects managed through the tunnel service REST API. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct TunnelPortV2 { // Gets or sets the ID of the cluster the tunnel was created in. pub cluster_id: Option, // Gets or sets the generated ID of the tunnel, unique within the cluster. pub tunnel_id: Option, // Gets or sets the IP port number of the tunnel port. pub port_number: u16, // Gets or sets the optional short name of the port. // // The name must be unique among named ports of the same tunnel. pub name: Option, // Gets or sets the optional description of the port. pub description: Option, // Gets or sets the tags of the port. #[serde(skip_serializing_if = "Vec::is_empty", default)] pub labels: Vec, // Gets or sets the protocol of the tunnel port. // // Should be one of the string constants from `TunnelProtocol`. pub protocol: Option, // Gets or sets a value indicating whether this port is a default port for the tunnel. // // A client that connects to a tunnel (by ID or name) without specifying a port number // will connect to the default port for the tunnel, if a default is configured. Or if // the tunnel has only one port then the single port is the implicit default. // // Selection of a default port for a connection also depends on matching the // connection to the port `TunnelPortV2.Protocol`, so it is possible to configure // separate defaults for distinct protocols like `TunnelProtocol.Http` and // `TunnelProtocol.Ssh`. #[serde(default)] pub is_default: bool, // Gets or sets a dictionary mapping from scopes to tunnel access tokens. // // Unlike the tokens in `Tunnel.AccessTokens`, these tokens are restricted to the // individual port. pub access_tokens: Option>, // Gets or sets access control settings for the tunnel port. // // See `TunnelAccessControl` documentation for details about the access control model. pub access_control: Option, // Gets or sets options for the tunnel port. pub options: Option, // Gets or sets current connection status of the tunnel port. pub status: Option, // Gets or sets the username for the ssh service user is trying to forward. // // Should be provided if the `TunnelProtocol` is Ssh. pub ssh_user: Option, // Gets or sets web forwarding URIs. If set, it's a list of absolute URIs where the // port can be accessed with web forwarding. #[serde(skip_serializing_if = "Vec::is_empty", default)] pub port_forwarding_uris: Vec, // Gets or sets inspection URI. If set, it's an absolute URIs where the port's traffic // can be inspected. pub inspection_uri: Option, } dev-tunnels-0.0.25/rs/src/contracts/tunnel_protocol.rs000066400000000000000000000013731450757157500230750ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelProtocol.cs // Defines possible values for the protocol of a `TunnelPort`. // The protocol is automatically detected. (TODO: Define detection semantics.) pub const TUNNEL_PROTOCOL_AUTO: &str = r#"auto"#; // Unknown TCP protocol. pub const TUNNEL_PROTOCOL_TCP: &str = r#"tcp"#; // Unknown UDP protocol. pub const TUNNEL_PROTOCOL_UDP: &str = r#"udp"#; // SSH protocol. pub const TUNNEL_PROTOCOL_SSH: &str = r#"ssh"#; // Remote desktop protocol. pub const TUNNEL_PROTOCOL_RDP: &str = r#"rdp"#; // HTTP protocol. pub const TUNNEL_PROTOCOL_HTTP: &str = r#"http"#; // HTTPS protocol. pub const TUNNEL_PROTOCOL_HTTPS: &str = r#"https"#; dev-tunnels-0.0.25/rs/src/contracts/tunnel_relay_tunnel_endpoint.rs000066400000000000000000000012541450757157500256330ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelRelayTunnelEndpoint.cs use crate::contracts::TunnelEndpoint; use serde::{Deserialize, Serialize}; // Parameters for connecting to a tunnel via the tunnel service's built-in relay function. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct TunnelRelayTunnelEndpoint { #[serde(flatten)] pub base: TunnelEndpoint, // Gets or sets the host URI. pub host_relay_uri: Option, // Gets or sets the client URI. pub client_relay_uri: Option, } dev-tunnels-0.0.25/rs/src/contracts/tunnel_service_properties.rs000066400000000000000000000064571450757157500251600ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelServiceProperties.cs use serde::{Deserialize, Serialize}; // Provides environment-dependent properties about the service. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct TunnelServiceProperties { // Gets the base URI of the service. pub service_uri: String, // Gets the public AAD AppId for the service. // // Clients specify this AppId as the audience property when authenticating to the // service. pub service_app_id: String, // Gets the internal AAD AppId for the service. // // Other internal services specify this AppId as the audience property when // authenticating to the tunnel service. Production services must be in the AME tenant // to use this appid. pub service_internal_app_id: String, // Gets the client ID for the service's GitHub app. // // Clients apps that authenticate tunnel users with GitHub specify this as the client // ID when requesting a user token. pub github_app_client_id: String, } // Global DNS name of the production tunnel service. pub const PROD_DNS_NAME: &str = "global.rel.tunnels.api.visualstudio.com"; // Global DNS name of the pre-production tunnel service. pub const PPE_DNS_NAME: &str = "global.rel.tunnels.ppe.api.visualstudio.com"; // Global DNS name of the development tunnel service. pub const DEV_DNS_NAME: &str = "global.ci.tunnels.dev.api.visualstudio.com"; // First-party app ID: `Visual Studio Tunnel Service` // // Used for authenticating AAD/MSA users, and service principals outside the AME tenant, // in the PROD service environment. pub const PROD_FIRST_PARTY_APP_ID: &str = "46da2f7e-b5ef-422a-88d4-2a7f9de6a0b2"; // First-party app ID: `Visual Studio Tunnel Service - Test` // // Used for authenticating AAD/MSA users, and service principals outside the AME tenant, // in the PPE and DEV service environments. pub const NON_PROD_FIRST_PARTY_APP_ID: &str = "54c45752-bacd-424a-b928-652f3eca2b18"; // Third-party app ID: `tunnels-prod-app-sp` // // Used for authenticating internal AAD service principals in the AME tenant, in the PROD // service environment. pub const PROD_THIRD_PARTY_APP_ID: &str = "ce65d243-a913-4cae-a7dd-cb52e9f77647"; // Third-party app ID: `tunnels-ppe-app-sp` // // Used for authenticating internal AAD service principals in the AME tenant, in the PPE // service environment. pub const PPE_THIRD_PARTY_APP_ID: &str = "544167a6-f431-4518-aac6-2fd50071928e"; // Third-party app ID: `tunnels-dev-app-sp` // // Used for authenticating internal AAD service principals in the corp tenant (not AME!), // in the DEV service environment. pub const DEV_THIRD_PARTY_APP_ID: &str = "a118c979-0249-44bb-8f95-eb0457127aeb"; // GitHub App Client ID for 'Visual Studio Tunnel Service' // // Used by client apps that authenticate tunnel users with GitHub, in the PROD service // environment. pub const PROD_GITHUB_APP_CLIENT_ID: &str = "Iv1.e7b89e013f801f03"; // GitHub App Client ID for 'Visual Studio Tunnel Service - Test' // // Used by client apps that authenticate tunnel users with GitHub, in the PPE and DEV // service environments. pub const NON_PROD_GITHUB_APP_CLIENT_ID: &str = "Iv1.b231c327f1eaa229"; dev-tunnels-0.0.25/rs/src/contracts/tunnel_status.rs000066400000000000000000000107251450757157500225600ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelStatus.cs use crate::contracts::RateStatus; use crate::contracts::ResourceStatus; use serde::{Deserialize, Serialize}; // Data contract for `Tunnel` status. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct TunnelStatus { // Gets or sets the current value and limit for the number of ports on the tunnel. pub port_count: Option, // Gets or sets the current value and limit for the number of hosts currently // accepting connections to the tunnel. // // This is typically 0 or 1, but may be more than 1 if the tunnel options allow // multiple hosts. pub host_connection_count: Option, // Gets or sets the UTC time when a host was last accepting connections to the tunnel, // or null if a host has never connected. pub last_host_connection_time: Option, // Gets or sets the current value and limit for the number of clients connected to the // tunnel. // // This counts non-port-specific client connections, which is SDK and SSH clients. See // `TunnelPortStatus` for status of per-port client connections. pub client_connection_count: Option, // Gets or sets the UTC time when a client last connected to the tunnel, or null if a // client has never connected. // // This reports times for non-port-specific client connections, which is SDK client // and SSH clients. See `TunnelPortStatus` for per-port client connections. pub last_client_connection_time: Option, // Gets or sets the current value and limit for the rate of client connections to the // tunnel. // // This counts non-port-specific client connections, which is SDK client and SSH // clients. See `TunnelPortStatus` for status of per-port client connections. pub client_connection_rate: Option, // Gets or sets the current value and limit for the rate of bytes being received by // the tunnel host and uploaded by tunnel clients. // // All types of tunnel and port connections, from potentially multiple clients, can // contribute to this rate. The reported rate may differ slightly from the rate // measurable by applications, due to protocol overhead. Data rate status reporting is // delayed by a few seconds, so this value is a snapshot of the data transfer rate // from a few seconds earlier. pub upload_rate: Option, // Gets or sets the current value and limit for the rate of bytes being sent by the // tunnel host and downloaded by tunnel clients. // // All types of tunnel and port connections, from potentially multiple clients, can // contribute to this rate. The reported rate may differ slightly from the rate // measurable by applications, due to protocol overhead. Data rate status reporting is // delayed by a few seconds, so this value is a snapshot of the data transfer rate // from a few seconds earlier. pub download_rate: Option, // Gets or sets the total number of bytes received by the tunnel host and uploaded by // tunnel clients, over the lifetime of the tunnel. // // All types of tunnel and port connections, from potentially multiple clients, can // contribute to this total. The reported value may differ slightly from the value // measurable by applications, due to protocol overhead. Data transfer status // reporting is delayed by a few seconds. pub upload_total: Option, // Gets or sets the total number of bytes sent by the tunnel host and downloaded by // tunnel clients, over the lifetime of the tunnel. // // All types of tunnel and port connections, from potentially multiple clients, can // contribute to this total. The reported value may differ slightly from the value // measurable by applications, due to protocol overhead. Data transfer status // reporting is delayed by a few seconds. pub download_total: Option, // Gets or sets the current value and limit for the rate of management API read // operations for the tunnel or tunnel ports. pub api_read_rate: Option, // Gets or sets the current value and limit for the rate of management API update // operations for the tunnel or tunnel ports. pub api_update_rate: Option, } dev-tunnels-0.0.25/rs/src/contracts/tunnel_v2.rs000066400000000000000000000060541450757157500215640ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelV2.cs use chrono::{DateTime, Utc}; use crate::contracts::TunnelAccessControl; use crate::contracts::TunnelEndpoint; use crate::contracts::TunnelOptions; use crate::contracts::TunnelPortV2; use crate::contracts::TunnelStatus; use serde::{Deserialize, Serialize}; use std::collections::HashMap; // Data contract for tunnel objects managed through the tunnel service REST API. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct TunnelV2 { // Gets or sets the ID of the cluster the tunnel was created in. pub cluster_id: Option, // Gets or sets the generated ID of the tunnel, unique within the cluster. pub tunnel_id: Option, // Gets or sets the optional short name (alias) of the tunnel. // // The name must be globally unique within the parent domain, and must be a valid // subdomain. pub name: Option, // Gets or sets the description of the tunnel. pub description: Option, // Gets or sets the tags of the tunnel. #[serde(skip_serializing_if = "Vec::is_empty", default)] pub labels: Vec, // Gets or sets the optional parent domain of the tunnel, if it is not using the // default parent domain. pub domain: Option, // Gets or sets a dictionary mapping from scopes to tunnel access tokens. pub access_tokens: Option>, // Gets or sets access control settings for the tunnel. // // See `TunnelAccessControl` documentation for details about the access control model. pub access_control: Option, // Gets or sets default options for the tunnel. pub options: Option, // Gets or sets current connection status of the tunnel. pub status: Option, // Gets or sets an array of endpoints where hosts are currently accepting client // connections to the tunnel. #[serde(skip_serializing_if = "Vec::is_empty", default)] pub endpoints: Vec, // Gets or sets a list of ports in the tunnel. // // This optional property enables getting info about all ports in a tunnel at the same // time as getting tunnel info, or creating one or more ports at the same time as // creating a tunnel. It is omitted when listing (multiple) tunnels, or when updating // tunnel properties. (For the latter, use APIs to create/update/delete individual // ports instead.) #[serde(skip_serializing_if = "Vec::is_empty", default)] pub ports: Vec, // Gets or sets the time in UTC of tunnel creation. pub created: Option>, // Gets or the time the tunnel will be deleted if it is not used or updated. pub expiration: Option>, // Gets or the custom amount of time the tunnel will be valid if it is not used or // updated in seconds. pub custom_expiration: Option, } dev-tunnels-0.0.25/rs/src/lib.rs000066400000000000000000000001351450757157500164100ustar00rootroot00000000000000pub mod contracts; pub mod management; #[cfg(feature = "connections")] pub mod connections; dev-tunnels-0.0.25/rs/src/management/000077500000000000000000000000001450757157500174115ustar00rootroot00000000000000dev-tunnels-0.0.25/rs/src/management/authorization.rs000066400000000000000000000024551450757157500226650ustar00rootroot00000000000000use async_trait::async_trait; use super::HttpError; #[derive(Clone)] pub enum Authorization { /// No authorization. Anonymous, /// Authentication scheme for AAD (or Microsoft account) access tokens. AAD(String), /// Authentication scheme for GitHub access tokens Github(String), /// Authentication scheme for tunnel access tokens Tunnel(String), /// Authentication scheme for classic OAuth bearer tokens. Bearer(String), } impl Authorization { pub fn as_header(&self) -> Option { match self { Authorization::AAD(token) => Some(format!("aad {}", token)), Authorization::Github(token) => Some(format!("github {}", token)), Authorization::Tunnel(token) => Some(format!("tunnel {}", token)), Authorization::Bearer(token) => Some(format!("bearer {}", token)), Authorization::Anonymous => None, } } } #[async_trait] pub trait AuthorizationProvider: Send + Sync { async fn get_authorization(&self) -> Result; } pub(crate) struct StaticAuthorizationProvider(pub Authorization); #[async_trait] impl AuthorizationProvider for StaticAuthorizationProvider { async fn get_authorization(&self) -> Result { Ok(self.0.clone()) } } dev-tunnels-0.0.25/rs/src/management/errors.rs000066400000000000000000000043201450757157500212720ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. use std::{error::Error, fmt::Display}; use reqwest::StatusCode; use url::Url; use crate::contracts::ProblemDetails; /// Type of result returned from HTTP operations. pub type HttpResult = Result; /// Type of error returned from HTTP operations. #[derive(Debug)] pub enum HttpError { /// An error during connection to the remote. ConnectionError(reqwest::Error), /// An error returned from the remote server. ResponseError(ResponseError), /// An error was returned from the authorization callback. AuthorizationError(String), } impl Error for HttpError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { HttpError::ConnectionError(e) => Some(e), _ => None, } } } impl Display for HttpError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { HttpError::ConnectionError(e) => write!(f, "connection error: {}", e), HttpError::ResponseError(e) => write!(f, "response error: {}", e), HttpError::AuthorizationError(e) => write!(f, "authorization error: {}", e), } } } /// Part of the `HttpError` returned from a non-successfl response. #[derive(Debug)] pub struct ResponseError { /// Original request URL. pub url: Url, /// Response status code pub status_code: StatusCode, /// Error contents of the response, if any pub data: Option, /// Request ID for debugging purposes pub request_id: Option, } impl ResponseError { /// Attempts to get service problem details, if available. pub fn get_details(&self) -> Option { self.data .as_deref() .and_then(|d| serde_json::from_str(d).ok()) } } impl Display for ResponseError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "HTTP status {} from {} (request ID {}): {}", self.status_code, self.url, self.request_id.as_deref().unwrap_or(""), self.data.as_deref().unwrap_or("(empty body)") ) } } dev-tunnels-0.0.25/rs/src/management/http_client.rs000066400000000000000000000562471450757157500223120ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. use std::sync::Arc; use reqwest::{ header::{HeaderValue, AUTHORIZATION, CONTENT_TYPE}, Client, Method, Request, }; use serde::{de::DeserializeOwned, Serialize}; use url::Url; use crate::contracts::{ env_production, Tunnel, TunnelConnectionMode, TunnelEndpoint, TunnelPort, TunnelRelayTunnelEndpoint, TunnelServiceProperties, NamedRateStatus, }; use super::{ Authorization, AuthorizationProvider, HttpError, HttpResult, ResponseError, TunnelLocator, TunnelRequestOptions, NO_REQUEST_OPTIONS, }; #[derive(Clone)] pub struct TunnelManagementClient { client: Client, authorization: Arc>, pub(crate) user_agent: HeaderValue, environment: TunnelServiceProperties, } const TUNNELS_API_PATH: &str = "/api/v1/tunnels"; const USER_LIMITS_API_PATH: &str = "/api/v1/userlimits"; const ENDPOINTS_API_SUB_PATH: &str = "endpoints"; const PORTS_API_SUB_PATH: &str = "ports"; const CHECK_TUNNEL_NAME_SUB_PATH: &str = "/checkNameAvailability"; const PKG_VERSION: Option<&str> = option_env!("CARGO_PKG_VERSION"); impl TunnelManagementClient { /// Returns a builder that creates a new client, starting with the current /// client's options. pub fn build(&self) -> TunnelClientBuilder { TunnelClientBuilder { authorization: self.authorization.clone(), client: Some(self.client.clone()), user_agent: self.user_agent.clone(), environment: self.environment.clone(), } } /// Lists tunnels owned by the user. pub async fn list_all_tunnels( &self, options: &TunnelRequestOptions, ) -> HttpResult> { let mut url = self.build_uri(None, TUNNELS_API_PATH); url.query_pairs_mut().append_pair("global", "true"); let request = self.make_tunnel_request(Method::GET, url, options).await?; self.execute_json("list_all_tunnels", request).await } /// Lists tunnels owned by the user in a specific cluster. pub async fn list_cluster_tunnels( &self, cluster_id: &str, options: &TunnelRequestOptions, ) -> HttpResult> { let url = self.build_uri(Some(cluster_id), TUNNELS_API_PATH); let request = self.make_tunnel_request(Method::GET, url, options).await?; self.execute_json("list_cluster_tunnels", request).await } /// Looks up a tunnel by ID or name. pub async fn get_tunnel( &self, locator: &TunnelLocator, options: &TunnelRequestOptions, ) -> HttpResult { let url = self.build_tunnel_uri(locator, None); let request = self.make_tunnel_request(Method::GET, url, options).await?; self.execute_json("get_tunnel", request).await } /// Creates a new tunnel. pub async fn create_tunnel( &self, tunnel: &Tunnel, options: &TunnelRequestOptions, ) -> HttpResult { let url = self.build_uri(tunnel.cluster_id.as_deref(), TUNNELS_API_PATH); let mut request = self.make_tunnel_request(Method::POST, url, options).await?; json_body(&mut request, tunnel); self.execute_json("create_tunnel", request).await } /// Gets if tunnel name is avilable. pub async fn check_name_availability(&self, name: &str) -> HttpResult { let path = format!( "{}/{}{}", TUNNELS_API_PATH, name, CHECK_TUNNEL_NAME_SUB_PATH ); let url = self.build_uri(None, &path); let request = self .make_tunnel_request(Method::GET, url, NO_REQUEST_OPTIONS) .await?; self.execute_json("get_name_availability", request).await } /// Updates an existing tunnel. pub async fn update_tunnel( &self, tunnel: &Tunnel, options: &TunnelRequestOptions, ) -> HttpResult { let url = self.build_tunnel_uri(&tunnel.try_into().unwrap(), None); let mut request = self.make_tunnel_request(Method::PUT, url, options).await?; json_body(&mut request, tunnel); self.execute_json("update_tunnel", request).await } /// Deletes an existing tunnel. pub async fn delete_tunnel( &self, locator: &TunnelLocator, options: &TunnelRequestOptions, ) -> HttpResult { let url = self.build_tunnel_uri(locator, None); let request = self .make_tunnel_request(Method::DELETE, url, options) .await?; self.execute_no_response("delete_tunnel", request).await } /// Updates an existing tunnel's endpoints. pub async fn update_tunnel_endpoints( &self, locator: &TunnelLocator, endpoint: &TunnelEndpoint, options: &TunnelRequestOptions, ) -> HttpResult { let url = self.build_tunnel_uri( locator, Some(&format!( "{}/{}/{}", ENDPOINTS_API_SUB_PATH, endpoint.host_id, endpoint.connection_mode )), ); let mut request = self.make_tunnel_request(Method::PUT, url, options).await?; json_body(&mut request, endpoint); self.execute_json("update_tunnel_endpoints", request).await } /// Updates an existing tunnel's endpoints with relay information. pub async fn update_tunnel_relay_endpoints( &self, locator: &TunnelLocator, endpoint: &TunnelRelayTunnelEndpoint, options: &TunnelRequestOptions, ) -> HttpResult { let url = self.build_tunnel_uri( locator, Some(&format!( "{}/{}/{}", ENDPOINTS_API_SUB_PATH, endpoint.base.host_id, endpoint.base.connection_mode )), ); let mut request = self.make_tunnel_request(Method::PUT, url, options).await?; json_body(&mut request, endpoint); self.execute_json("update_tunnel_relay_endpoints", request) .await } /// Deletes an existing tunnel's endpoints. pub async fn delete_tunnel_endpoints( &self, locator: &TunnelLocator, host_id: &str, connection_mode: Option, options: &TunnelRequestOptions, ) -> HttpResult { let path = if let Some(cm) = connection_mode { format!("{}/{}/{}", ENDPOINTS_API_SUB_PATH, host_id, cm) } else { format!("{}/{}", ENDPOINTS_API_SUB_PATH, host_id) }; let url = self.build_tunnel_uri(locator, Some(&path)); let request = self .make_tunnel_request(Method::DELETE, url, options) .await?; self.execute_no_response("delete_tunnel_endpoints", request) .await } /// List a tunnel's ports. pub async fn list_tunnel_ports( &self, locator: &TunnelLocator, options: &TunnelRequestOptions, ) -> HttpResult> { let url = self.build_tunnel_uri(locator, Some(PORTS_API_SUB_PATH)); let request = self.make_tunnel_request(Method::GET, url, options).await?; self.execute_json("list_tunnel_ports", request).await } /// Gets info about a specific tunnel port. pub async fn get_tunnel_port( &self, locator: &TunnelLocator, port_number: u16, options: &TunnelRequestOptions, ) -> HttpResult { let url = self.build_tunnel_uri( locator, Some(&format!("{}/{}", PORTS_API_SUB_PATH, port_number)), ); let request = self.make_tunnel_request(Method::GET, url, options).await?; self.execute_json("get_tunnel_port", request).await } /// Creates a new port for a tunnel. pub async fn create_tunnel_port( &self, locator: &TunnelLocator, port: &TunnelPort, options: &TunnelRequestOptions, ) -> HttpResult { let url = self.build_tunnel_uri(locator, Some(PORTS_API_SUB_PATH)); let mut request = self.make_tunnel_request(Method::POST, url, options).await?; json_body(&mut request, port); self.execute_json("create_tunnel_port", request).await } /// Updates an existing port on the tunnel. pub async fn update_tunnel_port( &self, locator: &TunnelLocator, port: &TunnelPort, options: &TunnelRequestOptions, ) -> HttpResult { let url = self.build_tunnel_uri( locator, Some(&format!("{}/{}", PORTS_API_SUB_PATH, port.port_number)), ); let mut request = self.make_tunnel_request(Method::PUT, url, options).await?; json_body(&mut request, port); self.execute_json("create_tunnel_port", request).await } /// Deletes an existing port on the tunnel. pub async fn delete_tunnel_port( &self, locator: &TunnelLocator, port_number: u16, options: &TunnelRequestOptions, ) -> HttpResult { let url = self.build_tunnel_uri( locator, Some(&format!("{}/{}", PORTS_API_SUB_PATH, port_number)), ); let request = self .make_tunnel_request(Method::DELETE, url, options) .await?; self.execute_no_response("delete_tunnel_port", request) .await } /// Lists all user limits. pub async fn list_user_limits( &self, options: &TunnelRequestOptions, ) -> HttpResult> { let url = self.build_uri(None, USER_LIMITS_API_PATH); let request = self.make_tunnel_request(Method::GET, url, options).await?; self.execute_json("list_user_limits", request).await } /// Sends the request and deserializes a JSON response #[cfg(feature = "instrumentation")] async fn execute_json(&self, feature: &'static str, request: Request) -> HttpResult where T: DeserializeOwned, { use opentelemetry::{ global, trace::{TraceContextExt, Tracer}, }; let tracer = global::tracer("tunneling"); let span = tracer.start(feature); let cx = opentelemetry::Context::current_with_span(span); let guard = cx.clone().attach(); let res = self.execute_json_simple(request).await; if let Err(e) = &res { cx.span().record_exception(e); } drop(guard); res } /// Executes a request in which 200 status codes indicate success and /// 404 indicates an unsuccessful deletion but is not an error. async fn execute_no_response(&self, _: &'static str, request: Request) -> HttpResult { let url_clone = request.url().clone(); let res = self .client .execute(request) .await .map_err(HttpError::ConnectionError)?; if res.status().is_success() { Ok(true) } else if res.status().as_u16() == 404 { Ok(false) } else { let request_id = res .headers() .get("VsSaaS-Request-Id") .and_then(|h| h.to_str().ok()) .map(|s| s.to_owned()); Err(HttpError::ResponseError(ResponseError { url: url_clone, status_code: res.status(), data: res.text().await.ok(), request_id, })) } } /// Sends the request and deserializes a JSON response #[cfg(not(feature = "instrumentation"))] async fn execute_json(&self, _: &'static str, request: Request) -> HttpResult where T: DeserializeOwned, { self.execute_json_simple(request).await } async fn execute_json_simple(&self, request: Request) -> HttpResult where T: DeserializeOwned, { let url_clone = request.url().clone(); let res = self .client .execute(request) .await .map_err(HttpError::ConnectionError)?; if res.status().is_success() { res.json::().await.map_err(HttpError::ConnectionError) } else { let request_id = res .headers() .get("VsSaaS-Request-Id") .and_then(|h| h.to_str().ok()) .map(|s| s.to_owned()); Err(HttpError::ResponseError(ResponseError { url: url_clone, status_code: res.status(), data: res.text().await.ok(), request_id, })) } } /// Builds a URI that does an operation on a tunnel. fn build_tunnel_uri(&self, locator: &TunnelLocator, path: Option<&str>) -> Url { let make_path = |ident: &str| { path.map(|p| format!("{}/{}/{}", TUNNELS_API_PATH, ident, p)) .unwrap_or_else(|| format!("{}/{}", TUNNELS_API_PATH, ident)) }; match locator { TunnelLocator::Name(name) => self.build_uri(None, &make_path(name)), TunnelLocator::ID { cluster, id } => self.build_uri(Some(cluster), &make_path(id)), } } /// Builds a URI to a path on the given cluster, if given, or to the global /// service if nont is provided. fn build_uri(&self, cluster_id: Option<&str>, path: &str) -> Url { let mut uri = Url::parse(&self.environment.service_uri).expect("expected valid service_uri"); if let Some(cluster_id) = cluster_id { let hostname = uri.host_str().unwrap_or(""); if !hostname.starts_with(&format!("{}.", cluster_id)) { let new_hostname = format!("{}.{}", cluster_id, hostname).replace("global.", ""); uri.set_host(Some(&new_hostname)).unwrap(); } } uri.set_path(path); uri } /// Makes a request and applies the additional tunnel options to the headers and query string. async fn make_tunnel_request( &self, method: Method, mut url: Url, tunnel_opts: &TunnelRequestOptions, ) -> HttpResult { add_query(&mut url, tunnel_opts); let mut request = self.make_request(method, url).await?; let headers = request.headers_mut(); if let Some(authorization) = &tunnel_opts.authorization { if let Some(a) = authorization.as_header() { headers.insert(AUTHORIZATION, HeaderValue::from_str(&a).unwrap()); } else { headers.remove(AUTHORIZATION); } } for (name, value) in &tunnel_opts.headers { headers.append(name, value.to_owned()); } Ok(request) } /// Makes a basic request that communicates with the service. async fn make_request(&self, method: Method, url: Url) -> HttpResult { let mut request = Request::new(method, url); let headers = request.headers_mut(); headers.insert("User-Agent", self.user_agent.clone()); if let Some(a) = self.authorization.get_authorization().await?.as_header() { headers.insert(AUTHORIZATION, HeaderValue::from_str(&a).unwrap()); } Ok(request) } } fn json_body(request: &mut Request, body: T) where T: Serialize, { request .headers_mut() .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); *request.body_mut() = Some(serde_json::to_vec(&body).unwrap().into()); } pub struct TunnelClientBuilder { authorization: Arc>, client: Option, user_agent: HeaderValue, environment: TunnelServiceProperties, } /// Creates a new tunnel client builder. You can set options, then use `into()` /// to get the client instance (or cast automatically). pub fn new_tunnel_management(user_agent: &str) -> TunnelClientBuilder { let pkg_version = PKG_VERSION.unwrap_or("unknown"); let full_user_agent = format!( "{}{}{}", user_agent, " Dev-Tunnels-Service-Rust-SDK/", pkg_version ); TunnelClientBuilder { authorization: Arc::new(Box::new(super::StaticAuthorizationProvider( Authorization::Anonymous, ))), client: None, user_agent: HeaderValue::from_str(&full_user_agent).unwrap(), environment: env_production(), } } impl TunnelClientBuilder { pub fn authorization(&mut self, authorization: Authorization) -> &mut Self { self.authorization = Arc::new(Box::new(super::StaticAuthorizationProvider(authorization))); self } pub fn authorization_provider( &mut self, provider: impl AuthorizationProvider + 'static, ) -> &mut Self { self.authorization = Arc::new(Box::new(provider)); self } pub fn client(&mut self, client: Client) -> &mut Self { self.client = Some(client); self } pub fn environment(&mut self, environment: TunnelServiceProperties) -> &mut Self { self.environment = environment; self } } impl From for TunnelManagementClient { fn from(builder: TunnelClientBuilder) -> Self { TunnelManagementClient { authorization: builder.authorization, client: builder.client.unwrap_or_else(Client::new), user_agent: builder.user_agent, environment: builder.environment, } } } fn add_query(url: &mut Url, tunnel_opts: &TunnelRequestOptions) { if tunnel_opts.include_ports { url.query_pairs_mut().append_pair("includePorts", "true"); } if tunnel_opts.include_access_control { url.query_pairs_mut() .append_pair("includeAccessControl", "true"); } if !tunnel_opts.token_scopes.is_empty() { url.query_pairs_mut() .append_pair("tokenScopes", &tunnel_opts.token_scopes.join(",")); } if tunnel_opts.force_rename { url.query_pairs_mut().append_pair("forceRename", "true"); } if !tunnel_opts.tags.is_empty() { url.query_pairs_mut() .append_pair("tags", &tunnel_opts.tags.join(",")); if tunnel_opts.require_all_tags { url.query_pairs_mut().append_pair("allTags", "true"); } } if tunnel_opts.limit > 0 { url.query_pairs_mut() .append_pair("limit", &tunnel_opts.limit.to_string()); } } // End to end tests can be run with `cargo test --features end_to_end -- --nocapture` // with an environment variable TUNNEL_TEST_CLIENT_ID. #[cfg(test)] #[cfg(feature = "end_to_end")] mod test_end_to_end { use std::{env, time::Duration}; use async_trait::async_trait; use serde::Deserialize; use tokio::time::sleep; use crate::{ contracts::{Tunnel, PROD_FIRST_PARTY_APP_ID}, management::{ Authorization, AuthorizationProvider, HttpError, TunnelLocator, NO_REQUEST_OPTIONS, }, }; use super::{new_tunnel_management, TunnelManagementClient}; #[tokio::test] async fn round_trips_tunnel() { let c = get_client().await; // create tunnel let tunnel = c .create_tunnel(&Tunnel::default(), NO_REQUEST_OPTIONS) .await .unwrap(); assert!(tunnel.tunnel_id.is_some()); let ident = TunnelLocator::try_from(&tunnel).unwrap(); // get tunnel let tunnel2 = c.get_tunnel(&ident, NO_REQUEST_OPTIONS).await.unwrap(); assert_eq!(tunnel.tunnel_id, tunnel2.tunnel_id); // appears in list tunnels let tunnels = c.list_all_tunnels(NO_REQUEST_OPTIONS).await.unwrap(); assert!(tunnels .iter() .find(|t| t.tunnel_id == tunnel.tunnel_id) .is_some()); // delete tunnel c.delete_tunnel(&ident, NO_REQUEST_OPTIONS).await.unwrap(); } #[derive(Deserialize)] struct DeviceCodeResponse { device_code: String, message: String, } #[derive(Deserialize)] struct AuthenticationResponse { access_token: String, } async fn do_device_code_flow(client: &reqwest::Client) -> String { let client_id = match env::var("TUNNEL_TEST_CLIENT_ID") { Ok(value) => value, _ => panic!("TUNNEL_TEST_CLIENT_ID must be set"), }; let base_uri = "https://login.microsoftonline.com/organizations/oauth2/v2.0"; let verification = client .post(format!("{}/devicecode", base_uri)) .body(format!( "client_id={}&scope={}/.default", client_id, PROD_FIRST_PARTY_APP_ID )) .send() .await .unwrap() .json::() .await .unwrap(); println!("{}", verification.message); loop { sleep(Duration::from_secs(5)).await; let response = client.post(format!("{}/token", base_uri)) .body(format!( "client_id={}&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code={}", client_id, verification.device_code )) .send() .await .unwrap(); if !response.status().is_success() { continue; } let body = response.json::().await.unwrap(); println!("accessToken is {}", body.access_token); println!( "You can save this in the TUNNEL_TEST_AAD_TOKEN environment variable for next time" ); return body.access_token; } } struct AuthCodeProvider(); #[async_trait] impl AuthorizationProvider for AuthCodeProvider { async fn get_authorization(&self) -> Result { let token = match env::var("TUNNEL_TEST_AAD_TOKEN") { Ok(value) => value, _ => do_device_code_flow(&reqwest::Client::new()).await, }; env::set_var("TUNNEL_TEST_AAD_TOKEN", &token); Ok(Authorization::Bearer(token)) } } async fn get_client() -> TunnelManagementClient { let mut c = new_tunnel_management("rs-sdk-tests"); c.authorization_provider(AuthCodeProvider()); c.into() } } #[cfg(test)] mod tests { use regex::Regex; use reqwest::Url; use crate::management::NO_REQUEST_OPTIONS; #[test] fn new_tunnel_management_has_user_agent() { // test let builder = super::new_tunnel_management("test-caller"); // verify let re = Regex::new( r"^test-caller Dev-Tunnels-Service-Rust-SDK/[0-9]+\.[0-9]+\.[0-9]+$", ) .unwrap(); let full_agent = builder.user_agent.to_str().unwrap(); assert!(re.is_match(full_agent)); } #[test] fn add_query_omits_empty_query() { let mut url = Url::parse("https://tunnels.api.visualstudio.com/api/v1/tunnels").unwrap(); let options = NO_REQUEST_OPTIONS; super::add_query(&mut url, options); assert!(!url.to_string().ends_with("?")); } #[test] fn add_query_adds_ports() { let mut url = Url::parse("https://tunnels.api.visualstudio.com/api/v1/tunnels").unwrap(); let mut options = NO_REQUEST_OPTIONS.clone(); options.include_ports = true; super::add_query(&mut url, &options); assert!(url.query().unwrap().contains("includePorts=true")); } } dev-tunnels-0.0.25/rs/src/management/mod.rs000066400000000000000000000003441450757157500205370ustar00rootroot00000000000000mod authorization; mod errors; mod http_client; mod tunnel_locator; mod tunnel_request_options; pub use authorization::*; pub use errors::*; pub use http_client::*; pub use tunnel_locator::*; pub use tunnel_request_options::*; dev-tunnels-0.0.25/rs/src/management/tunnel_locator.rs000066400000000000000000000014641450757157500230140ustar00rootroot00000000000000use crate::contracts::Tunnel; #[derive(Clone, Debug)] pub enum TunnelLocator { /// Tunnel by its unique name. Name(String), /// Tunnel by its ID and cluster where it's located. ID { cluster: String, id: String }, } impl TryFrom<&Tunnel> for TunnelLocator { type Error = &'static str; fn try_from(tunnel: &Tunnel) -> Result { if let (Some(cluster), Some(id)) = (&tunnel.cluster_id, &tunnel.tunnel_id) { return Ok(TunnelLocator::ID { cluster: cluster.to_owned(), id: id.to_owned(), }); } if let Some(name) = &tunnel.name { if !name.is_empty() { return Ok(TunnelLocator::Name(name.to_owned())); } } Err("Tunnel has no name or ID") } } dev-tunnels-0.0.25/rs/src/management/tunnel_request_options.rs000066400000000000000000000052271450757157500246150ustar00rootroot00000000000000use reqwest::header::{HeaderName, HeaderValue}; use super::Authorization; #[derive(Default, Clone)] pub struct TunnelRequestOptions { /// Gets or sets authorization for the request. /// /// Note this should not be a _user_ access token (such as AAD or GitHub); use the /// callback parameter to the `TunnelManagementClient` constructor to /// supply user access tokens. pub authorization: Option, /// Gets or sets additional headers to be included in the request. pub headers: Vec<(HeaderName, HeaderValue)>, /// Gets or sets a flag that requests tunnel ports when retrieving a tunnel object. /// /// Ports are excluded by default when retrieving a tunnel or when listing or searching /// tunnels. This option enables including ports for all tunnels returned by a list or /// search query. pub include_ports: bool, /// Gets or sets a flag that requests access control details when retrieving tunnels. /// /// Access control details are always included when retrieving a single tunnel, /// but excluded by default when listing or searching tunnels. This option enables /// including access controls for all tunnels returned by a list or search query. pub include_access_control: bool, /// Gets or sets an optional list of tags to filter the requested tunnels or ports. /// /// Requested tags are compared to the `Tunnel.tags` or `TunnelPort.tags` when calling /// `TunnelManagementClient.list_all_tunnels` or `TunnelManagementClient.list_tunnel_ports` /// respectively. By default, an item is included if ANY tag matches; set `require_all_tags` /// to match ALL tags instead. pub tags: Vec, /// Gets or sets a flag that indicates whether listed items must match all tags /// specified in `tags`. If false, an item is included if any tag matches. pub require_all_tags: bool, /// Gets or sets an optional list of token scopes that /// are requested when retrieving a tunnel or tunnel port object. pub token_scopes: Vec, /// If true on a create or update request then upon a name conflict, attempt to rename the /// existing tunnel to null and give the name to the tunnel from the request. pub force_rename: bool, /// Limits the number of tunnels returned when searching or listing tunnels. pub limit: u32, } pub const NO_REQUEST_OPTIONS: &TunnelRequestOptions = &TunnelRequestOptions { authorization: None, headers: Vec::new(), include_ports: false, include_access_control: false, tags: Vec::new(), require_all_tags: false, token_scopes: Vec::new(), force_rename: false, limit: 0, }; dev-tunnels-0.0.25/rs/src/tunnel.rs000066400000000000000000000003671450757157500171560ustar00rootroot00000000000000use chrono::{DateTime, Utc}; use serde::{Serialize, Deserialize}; #[derive(Serialize, Deserialize)] #[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] pub struct Tunnel { tunnel_id: String, created: DateTime, } dev-tunnels-0.0.25/rs/test/000077500000000000000000000000001450757157500154655ustar00rootroot00000000000000dev-tunnels-0.0.25/rs/test/tunnels_test.rs000066400000000000000000000001151450757157500205570ustar00rootroot00000000000000#[test] fn it_works() { let result = 2 + 2; assert_eq!(result, 4); } dev-tunnels-0.0.25/ts/000077500000000000000000000000001450757157500145105ustar00rootroot00000000000000dev-tunnels-0.0.25/ts/.eslintrc.js000066400000000000000000000052171450757157500167540ustar00rootroot00000000000000module.exports = { "env": { "browser": true, "es6": true, "node": true }, "extends": [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended', "prettier" ], "parser": "@typescript-eslint/parser", "parserOptions": { "project": ["src/*/tsconfig.json"], "sourceType": "module" }, "plugins": [ "eslint-plugin-security", "eslint-plugin-jsdoc", "@typescript-eslint" ], "root": true, "rules": { "@typescript-eslint/ban-types": [ "error", { "types": { "BigInt": false, } } ], "@typescript-eslint/dot-notation": "error", "@typescript-eslint/explicit-member-accessibility": [ "error", { "accessibility": "explicit" } ], "@typescript-eslint/naming-convention": [ "error", { "selector": "variable", "format": [ "camelCase", "UPPER_CASE" ], "leadingUnderscore": "forbid", "trailingUnderscore": "forbid" } ], "@typescript-eslint/no-dynamic-delete": "error", "@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-floating-promises": "error", "@typescript-eslint/no-inferrable-types": "off", "@typescript-eslint/no-namespace": "off", "@typescript-eslint/no-non-null-assertion": "off", "@typescript-eslint/no-require-imports": "off", "@typescript-eslint/no-shadow": [ "error", { "hoist": "all" } ], "@typescript-eslint/no-unused-expressions": "error", "@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-use-before-define": "off", "@typescript-eslint/no-var-requires": "off", "@typescript-eslint/prefer-namespace-keyword": "error", "@typescript-eslint/promise-function-async": "off", "brace-style": [ "error", "1tbs" ], "curly": "off", "default-case": "error", "dot-notation": "off", "eqeqeq": [ "error", "smart" ], "guard-for-in": "error", "id-denylist": "error", "id-match": "error", "indent": "off", "jsdoc/check-alignment": "error", "jsdoc/check-indentation": "off", "jsdoc/newline-after-description": "off", "no-caller": "error", "no-cond-assign": "error", "no-debugger": "error", "no-empty": "off", "no-empty-function": "off", "no-eval": "error", "no-fallthrough": "error", "no-inner-declarations": "off", "no-multiple-empty-lines": "error", "no-new-wrappers": "error", "no-redeclare": "error", "no-throw-literal": "error", "no-underscore-dangle": "error", "no-unused-expressions": "off", "no-unused-labels": "error", "no-unused-vars": "off", "no-var": "error", "radix": "error", "security/detect-non-literal-require": "error", "security/detect-possible-timing-attacks": "error" } }; dev-tunnels-0.0.25/ts/.gitignore000066400000000000000000000000221450757157500164720ustar00rootroot00000000000000out/ node_modules/dev-tunnels-0.0.25/ts/.npmrc000066400000000000000000000000441450757157500156260ustar00rootroot00000000000000registy=https://registry.npmjs.org/ dev-tunnels-0.0.25/ts/.prettierrc000066400000000000000000000003101450757157500166660ustar00rootroot00000000000000{ "printWidth": 100, "tabWidth": 4, "semi": true, "singleQuote": true, "trailingComma": "es5", "arrowParens": "always", "parser": "typescript", "jsxSingleQuote": true }dev-tunnels-0.0.25/ts/.vscode/000077500000000000000000000000001450757157500160515ustar00rootroot00000000000000dev-tunnels-0.0.25/ts/.vscode/launch.json000066400000000000000000000033401450757157500202160ustar00rootroot00000000000000{ // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "type": "pwa-node", "request": "launch", "name": "Launch Client Connection", "skipFiles": [ "/**" ], "program": "${workspaceFolder}/out/lib/tunnels-test/connection.js", "outFiles": [ "${workspaceFolder}/**/*.js" ] }, { "type": "pwa-node", "request": "launch", "name": "Launch Host", "skipFiles": [ "/**" ], "program": "${workspaceFolder}/out/lib/tunnels-test/host.js", "outFiles": [ "${workspaceFolder}/**/*.js" ] }, { "type": "pwa-node", "request": "launch", "name": "Launch Tests", "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", "args": [ "--require", "source-map-support/register", "--timemout", "600000", "--reporter", "dot", "--colors", "${workspaceFolder}/out/lib/tunnels-test/*Tests.js", ], "internalConsoleOptions": "openOnSessionStart", "sourceMaps": true, "outFiles": [ "${workspaceFolder}/**/*.js" ], "skipFiles": [ "/**" ] } ] }dev-tunnels-0.0.25/ts/.vscode/settings.json000066400000000000000000000001231450757157500206000ustar00rootroot00000000000000{ "files.exclude": { "out/": true, "node_modules/": true, } }dev-tunnels-0.0.25/ts/README-dev.md000066400000000000000000000013161450757157500165440ustar00rootroot00000000000000# Tunnels Library Development This document contains information about building, debugging, testing, and benchmarking the Tunnels libraries. ## Prerequisites - Install **Node.js** version 10.x or above. - Install dependency packages: ``` npm install ``` ## Building Command-line builds are driven by scripts in `build.js`. | Command | Description | | --------------- | ------------------------------- | | `npm run build` | Build everything. | | `npm run pack` | Pack everything. | ### Node.js Testing ``` npm run test ``` To run/debug an individual test case or subset of test cases matching a substring, use a command similar to the following: dev-tunnels-0.0.25/ts/build.js000066400000000000000000000223701450757157500161510ustar00rootroot00000000000000// // Copyright (c) Microsoft Corporation. All rights reserved. // const child_process = require('child_process'); const os = require('os'); const fs = require('fs'); const glob = require('glob'); const moment = require('moment'); const path = require('path'); const util = require('util'); const yargs = require('yargs'); fs.readdir = util.promisify(fs.readdir); fs.copyFile = util.promisify(fs.copyFile); fs.rename = util.promisify(fs.rename); fs.readFile = util.promisify(fs.readFile); fs.writeFile = util.promisify(fs.writeFile); fs.mkdir = util.promisify(fs.mkdir); fs.unlink = util.promisify(fs.unlink); yargs.version(false); const buildGroup = 'Build Options:'; const testGroup = 'Test Options:'; yargs.option('verbosity', { desc: 'MSBuild verbosity', string: true, group: buildGroup }); yargs.option('configuration', { desc: 'MSBuild configuration', choices: ['Debug', 'Release'], group: buildGroup, }); yargs.option('release', { desc: 'Use MSBuild Release configuration', boolean: true, group: buildGroup, }); yargs.option('framework', { desc: 'Specify .net application framework', choices: ['netcoreapp2.1', 'netcoreapp2.1', 'net5.0', 'netstandard2.0', 'netstandard2.1'], group: buildGroup, }); yargs.option('msbuild', { desc: 'Use MSBuild instead of dotnet CLI', // Signing requires msbuild boolean: true, group: buildGroup, }); yargs.option('filter', { desc: 'Filter test cases', string: true, group: testGroup }); yargs.option('serial', { desc: 'Run tests serially (slower)', boolean: true, group: testGroup }); yargs.option('coverage', { desc: 'Collect code coverage when testing', boolean: true, group: testGroup, }); const namespace = 'Microsoft.VisualStudio.Tunnels'; const srcDir = path.join(__dirname, 'src'); const binDir = path.join(__dirname, 'out', 'bin'); const libDir = path.join(__dirname, 'out', 'lib'); const intermediateDir = path.join(__dirname, 'out', 'obj'); const packageDir = path.join(__dirname, 'out', 'pkg'); const packageJsonFile = path.join(__dirname, 'package.json'); const testResultsDir = path.join(__dirname, 'out', 'testresults'); function getPackageFileName(packageJson, buildVersion) { // '@scope/' gets converted to a 'scope-' prefix of the package filename. return `${packageJson.name.replace('@', '').replace('/', '-')}-${buildVersion}.tgz`; } yargs.command('build', 'Build TypeScript code', async () => { await forkCommand('build-ts'); }); yargs.command('pack', 'Build TypeScript packages', async () => { await forkCommand('pack-ts'); }); yargs.command('test', 'Test TypeScript code', async () => { await forkCommand('test-ts'); }); yargs.command('build-ts', 'Build TypeScript code', async (yargs) => { const tsPackageNames = ['contracts', 'management', 'connections']; for (let packageName of tsPackageNames) { await linkLib('@microsoft/dev-tunnels-' + packageName, packageName); } await executeCommand(__dirname, `npm run --silent compile`); await executeCommand(__dirname, `npm run --silent eslint`); const buildVersion = await getBuildVersion(); const rootPackageJson = JSON.parse(await fs.readFile(path.join(__dirname, 'package.json'))); // Update the package.json and README for each built package. for (let packageName of tsPackageNames) { const sourceDir = path.join(srcDir, packageName); const targetDir = path.join(libDir, packageName); const builtPackageJsonFile = path.join(targetDir, 'package.json'); const packageJson = JSON.parse(await fs.readFile(builtPackageJsonFile)); packageJson.version = buildVersion; packageJson.author = rootPackageJson.author; packageJson.scripts = undefined; packageJson.main = './index.js'; // Force the dependencies on other packages in this project to be the same build version. for (let packageName of Object.keys(packageJson.dependencies)) { if (packageName.startsWith(rootPackageJson.name + '-')) { packageJson.dependencies[packageName] = buildVersion; } } await fs.writeFile(builtPackageJsonFile, JSON.stringify(packageJson, null, '\t')); await fs.copyFile(path.join(sourceDir, 'README.md'), path.join(targetDir, 'README.md')); } }); yargs.command('pack-ts', 'Build TypeScript npm packages', async (yargs) => { const rootPackageJson = JSON.parse(await fs.readFile(path.join(__dirname, 'package.json'))); const buildVersion = await getBuildVersion(); await mkdirp(packageDir); let packageFiles = []; for (let packageName of ['contracts', 'management', 'connections']) { const sourceDir = path.join(srcDir, packageName); const targetDir = path.join(libDir, packageName); const packageJson = JSON.parse(await fs.readFile(path.join(sourceDir, 'package.json'))); packageJson.author = rootPackageJson.author; packageJson.version = buildVersion; packageJson.scripts = undefined; packageJson.main = './index.js'; await fs.writeFile( path.join(targetDir, 'package.json'), JSON.stringify(packageJson, null, '\t'), ); await fs.copyFile(path.join(sourceDir, 'README.md'), path.join(targetDir, 'README.md')); await executeCommand(targetDir, `npm pack`); const prefixedPackageFileName = getPackageFileName(packageJson, buildVersion); const packageFileName = prefixedPackageFileName.replace(/\w+-/, ''); await fs.rename( path.join(targetDir, prefixedPackageFileName), path.join(packageDir, packageFileName), ); packageFiles.push(packageFileName); } console.log(`Created packages [${packageFiles.join(', ')}] at ${packageDir}`); }); yargs.command('publish-ts', 'Publish TypeScrypt npm packages', async (yargs) => { const buildVersion = await getBuildVersion(); let fileName = `dev-tunnels-contracts-${buildVersion}.tgz`; let packageFilePath = path.join(packageDir, fileName); let publishCommand = `npm publish "${packageFilePath}"`; await executeCommand(__dirname, publishCommand); fileName = `dev-tunnels-management-${buildVersion}.tgz`; packageFilePath = path.join(packageDir, fileName); publishCommand = `npm publish "${packageFilePath}"`; await executeCommand(__dirname, publishCommand); fileName = `dev-tunnels-connections-${buildVersion}.tgz`; packageFilePath = path.join(packageDir, fileName); publishCommand = `npm publish "${packageFilePath}"`; await executeCommand(__dirname, publishCommand); }); yargs.command('test-ts', 'Run TypeScript tests', async (yargs) => { await mkdirp(testResultsDir); const testResultsFile = path.join( testResultsDir, `TUNNELS-TS_${moment().format('YYYY-MM-DD_HH-mm-ss-SSS')}.xml`, ); const reporterConfig = { reporterEnabled: 'spec, mocha-junit-reporter', mochaJunitReporterReporterOptions: { mochaFile: testResultsFile, }, }; const reporterConfigFile = path.join(testResultsDir, 'mocha-multi-reporters.config'); await fs.writeFile(reporterConfigFile, JSON.stringify(reporterConfig)); let command = 'npm run --silent mocha -- --reporter mocha-multi-reporters ' + `--reporter-options configFile="${reporterConfigFile}"`; if (yargs.argv.filter) { command += ` --grep /${yargs.argv.filter}/i`; } try { await executeCommand(__dirname, command); } finally { await fs.unlink(reporterConfigFile); } }); function forkCommand(command) { const args = [command, ...process.argv.slice(3)]; return new Promise((resolve) => { const options = { stdio: 'inherit', shell: true }; const p = child_process.fork(process.argv[1], args, options); p.on('close', (code) => { if (code) process.exit(code); resolve(); }); }); } function executeCommand(cwd, command, args) { if (!args) { const parts = command.split(' '); command = parts[0]; args = parts.slice(1); } console.log(`${command} ${args.join(' ')}`); return new Promise((resolve, reject) => { const options = { cwd: cwd, stdio: 'inherit', shell: true }; const p = child_process.spawn(command, args, options, (err) => { if (err) { err.showStack = false; reject(err); } resolve(); }); p.on('close', (code) => { if (code) reject(`Command exited with code ${code}: ${command}`); resolve(); }); }); } async function mkdirp(dir) { try { await fs.mkdir(dir, { recursive: true }); } catch (e) { if (e.code !== 'EEXIST') throw e; } } async function getBuildVersion() { const nbgv = require('nerdbank-gitversioning'); const buildVersion = await nbgv.getVersion(); return buildVersion.semVer2; } async function linkLib(packageName, dirName) { const libModuleFile = path.join(libDir, 'node_modules', packageName + '.js'); await mkdirp(path.dirname(libModuleFile)); await fs.writeFile( libModuleFile, '// Enable referencing this lib by package name instead of by relative path.\n' + `module.exports = require('../../${dirName}');\n`, ); } yargs.parseAsync().catch(console.error); dev-tunnels-0.0.25/ts/package-lock.json000066400000000000000000004143531450757157500177360ustar00rootroot00000000000000{ "name": "@microsoft/dev-tunnels", "requires": true, "lockfileVersion": 1, "dependencies": { "@es-joy/jsdoccomment": { "version": "0.37.0", "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.37.0.tgz", "integrity": "sha512-hjK0wnsPCYLlF+HHB4R/RbUjOWeLW2SlarB67+Do5WsKILOkmIZvvPJFbtWSmbypxcjpoECLAMzoao0D4Bg5ZQ==", "dev": true, "requires": { "comment-parser": "1.3.1", "esquery": "^1.4.0", "jsdoc-type-pratt-parser": "~4.0.0" } }, "@eslint-community/eslint-utils": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.3.0.tgz", "integrity": "sha512-v3oplH6FYCULtFuCeqyuTd9D2WKO937Dxdq+GmHOLL72TTRriLxz2VLlNfkZRsvj6PKnOPAtuT6dwrs/pA5DvA==", "dev": true, "requires": { "eslint-visitor-keys": "^3.3.0" } }, "@eslint-community/regexpp": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.4.0.tgz", "integrity": "sha512-A9983Q0LnDGdLPjxyXQ00sbV+K+O+ko2Dr+CZigbHWtX9pNfxlaBkMR8X1CztI73zuEyEBXTVjx7CE+/VSwDiQ==", "dev": true }, "@eslint/eslintrc": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.1.tgz", "integrity": "sha512-eFRmABvW2E5Ho6f5fHLqgena46rOj7r7OKHYfLElqcBfGFHHpjBhivyi5+jOEQuSpdc/1phIZJlbC2te+tZNIw==", "dev": true, "requires": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^9.5.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "@eslint/js": { "version": "8.36.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.36.0.tgz", "integrity": "sha512-lxJ9R5ygVm8ZWgYdUweoq5ownDlJ4upvoWmO4eLxBYHdMo+vZ/Rx0EN6MbKWDJOSUGrqJy2Gt+Dyv/VKml0fjg==", "dev": true }, "@humanwhocodes/config-array": { "version": "0.11.8", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==", "dev": true, "requires": { "@humanwhocodes/object-schema": "^1.2.1", "debug": "^4.1.1", "minimatch": "^3.0.5" } }, "@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true }, "@humanwhocodes/object-schema": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, "@microsoft/dev-tunnels-ssh": { "version": "3.11.31", "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-ssh/-/dev-tunnels-ssh-3.11.31.tgz", "integrity": "sha512-7L33PK0+4hdQF1kB+Zso9syIU/sVH11R63xNzbVvtOtKBCuamTGIRJH7UdvCWx/KkES/S3udFZ5e8aDRPzEBgw==", "requires": { "buffer": "^5.2.1", "debug": "^4.1.1", "diffie-hellman": "^5.0.3", "vscode-jsonrpc": "^4.0.0" } }, "@microsoft/dev-tunnels-ssh-tcp": { "version": "3.11.31", "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-ssh-tcp/-/dev-tunnels-ssh-tcp-3.11.31.tgz", "integrity": "sha512-zM+xRqRUTqXEuQtearhFCS+DBM0yGVXASaVL/Rx43t/T8oF6rW5E6YTTNs61/+v8nSgjVeQqrollWth7rxxxsw==", "requires": { "@microsoft/dev-tunnels-ssh": "~3.11" } }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, "requires": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "@nodelib/fs.stat": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true }, "@nodelib/fs.walk": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, "requires": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "@testdeck/core": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@testdeck/core/-/core-0.3.3.tgz", "integrity": "sha512-yu1yh7yluqnNDLe6Z18/y7kcmxBBEdfZAg3msG8covKkYPRbsCVr1+HmxReqJMKgeoh/UoEW1pi9Sz0fb/GYVQ==", "dev": true }, "@testdeck/mocha": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@testdeck/mocha/-/mocha-0.3.3.tgz", "integrity": "sha512-U5CX88u1G44SZ2LG2EFgh2SPYTczT5hCY7W8n41DCZMM61oKEkwDCiDBDi7IJVnLrPaNsceZpoVzosoepqIwog==", "dev": true, "requires": { "@testdeck/core": "^0.3.3" } }, "@types/debug": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz", "integrity": "sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==", "dev": true, "requires": { "@types/ms": "*" } }, "@types/events": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz", "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==", "dev": true }, "@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "dev": true }, "@types/mocha": { "version": "5.2.7", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-5.2.7.tgz", "integrity": "sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ==", "dev": true }, "@types/ms": { "version": "0.7.31", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", "integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==", "dev": true }, "@types/node": { "version": "18.15.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.3.tgz", "integrity": "sha512-p6ua9zBxz5otCmbpb5D3U4B5Nanw6Pk3PPyX05xnxbB/fRv71N7CPmORg7uAD5P70T0xmx1pzAx/FUfa5X+3cw==", "dev": true }, "@types/semver": { "version": "7.3.13", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==", "dev": true }, "@types/tmp": { "version": "0.0.34", "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.0.34.tgz", "integrity": "sha512-Tx7JYeYR+pkAnDQjN1Cj43KuOuUvyybZHl+fAezReXuH/SQoxLhsuPvHZH/SA4XtrBEhaTcbb5gVc1WQcjQgdg==", "dev": true }, "@types/uuid": { "version": "3.4.10", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-3.4.10.tgz", "integrity": "sha512-BgeaZuElf7DEYZhWYDTc/XcLZXdVgFkVSTa13BqKvbnmUrxr3TJFKofUxCtDO9UQOdhnV+HPOESdHiHKZOJV1A==", "dev": true }, "@types/websocket": { "version": "0.0.40", "resolved": "https://registry.npmjs.org/@types/websocket/-/websocket-0.0.40.tgz", "integrity": "sha512-ldteZwWIgl9cOy7FyvYn+39Ah4+PfpVE72eYKw75iy2L0zTbhbcwvzeJ5IOu6DQP93bjfXq0NGHY6FYtmYoqFQ==", "dev": true, "requires": { "@types/events": "*", "@types/node": "*" } }, "@types/yargs": { "version": "17.0.22", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.22.tgz", "integrity": "sha512-pet5WJ9U8yPVRhkwuEIp5ktAeAqRZOq4UdAyWLWzxbtpyXnzbtLdKiXAjJzi/KLmPGS9wk86lUFWZFN6sISo4g==", "dev": true, "requires": { "@types/yargs-parser": "*" } }, "@types/yargs-parser": { "version": "21.0.0", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz", "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", "dev": true }, "@typescript-eslint/eslint-plugin": { "version": "5.55.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.55.0.tgz", "integrity": "sha512-IZGc50rtbjk+xp5YQoJvmMPmJEYoC53SiKPXyqWfv15XoD2Y5Kju6zN0DwlmaGJp1Iw33JsWJcQ7nw0lGCGjVg==", "dev": true, "requires": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.55.0", "@typescript-eslint/type-utils": "5.55.0", "@typescript-eslint/utils": "5.55.0", "debug": "^4.3.4", "grapheme-splitter": "^1.0.4", "ignore": "^5.2.0", "natural-compare-lite": "^1.4.0", "semver": "^7.3.7", "tsutils": "^3.21.0" } }, "@typescript-eslint/parser": { "version": "5.55.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.55.0.tgz", "integrity": "sha512-ppvmeF7hvdhUUZWSd2EEWfzcFkjJzgNQzVST22nzg958CR+sphy8A6K7LXQZd6V75m1VKjp+J4g/PCEfSCmzhw==", "dev": true, "requires": { "@typescript-eslint/scope-manager": "5.55.0", "@typescript-eslint/types": "5.55.0", "@typescript-eslint/typescript-estree": "5.55.0", "debug": "^4.3.4" } }, "@typescript-eslint/scope-manager": { "version": "5.55.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.55.0.tgz", "integrity": "sha512-OK+cIO1ZGhJYNCL//a3ROpsd83psf4dUJ4j7pdNVzd5DmIk+ffkuUIX2vcZQbEW/IR41DYsfJTB19tpCboxQuw==", "dev": true, "requires": { "@typescript-eslint/types": "5.55.0", "@typescript-eslint/visitor-keys": "5.55.0" } }, "@typescript-eslint/type-utils": { "version": "5.55.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.55.0.tgz", "integrity": "sha512-ObqxBgHIXj8rBNm0yh8oORFrICcJuZPZTqtAFh0oZQyr5DnAHZWfyw54RwpEEH+fD8suZaI0YxvWu5tYE/WswA==", "dev": true, "requires": { "@typescript-eslint/typescript-estree": "5.55.0", "@typescript-eslint/utils": "5.55.0", "debug": "^4.3.4", "tsutils": "^3.21.0" } }, "@typescript-eslint/types": { "version": "5.55.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.55.0.tgz", "integrity": "sha512-M4iRh4AG1ChrOL6Y+mETEKGeDnT7Sparn6fhZ5LtVJF1909D5O4uqK+C5NPbLmpfZ0XIIxCdwzKiijpZUOvOug==", "dev": true }, "@typescript-eslint/typescript-estree": { "version": "5.55.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.55.0.tgz", "integrity": "sha512-I7X4A9ovA8gdpWMpr7b1BN9eEbvlEtWhQvpxp/yogt48fy9Lj3iE3ild/1H3jKBBIYj5YYJmS2+9ystVhC7eaQ==", "dev": true, "requires": { "@typescript-eslint/types": "5.55.0", "@typescript-eslint/visitor-keys": "5.55.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", "semver": "^7.3.7", "tsutils": "^3.21.0" } }, "@typescript-eslint/utils": { "version": "5.55.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.55.0.tgz", "integrity": "sha512-FkW+i2pQKcpDC3AY6DU54yl8Lfl14FVGYDgBTyGKB75cCwV3KpkpTMFi9d9j2WAJ4271LR2HeC5SEWF/CZmmfw==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@types/json-schema": "^7.0.9", "@types/semver": "^7.3.12", "@typescript-eslint/scope-manager": "5.55.0", "@typescript-eslint/types": "5.55.0", "@typescript-eslint/typescript-estree": "5.55.0", "eslint-scope": "^5.1.1", "semver": "^7.3.7" } }, "@typescript-eslint/visitor-keys": { "version": "5.55.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.55.0.tgz", "integrity": "sha512-q2dlHHwWgirKh1D3acnuApXG+VNXpEY5/AwRxDVuEQpxWaB0jCDe0jFMVMALJ3ebSfuOVE8/rMS+9ZOYGg1GWw==", "dev": true, "requires": { "@typescript-eslint/types": "5.55.0", "eslint-visitor-keys": "^3.3.0" } }, "@ungap/promise-all-settled": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", "dev": true }, "JSONStream": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", "dev": true, "requires": { "jsonparse": "^1.2.0", "through": ">=2.2.7 <3" } }, "acorn": { "version": "8.8.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", "dev": true }, "acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true }, "acorn-node": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", "dev": true, "requires": { "acorn": "^7.0.0", "acorn-walk": "^7.0.0", "xtend": "^4.0.2" }, "dependencies": { "acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", "dev": true } } }, "acorn-walk": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", "dev": true }, "ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "ansi-colors": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", "dev": true }, "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true }, "ansi-styles": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "requires": { "color-convert": "^1.9.0" } }, "anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dev": true, "requires": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, "array-from": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/array-from/-/array-from-2.1.1.tgz", "integrity": "sha512-GQTc6Uupx1FCavi5mPzBvVT7nEOeWMmUA9P95wpfpW1XwMSKs+KaymD5C2Up7KAUKg/mYwbsUYzdZWcoajlNZg==", "dev": true }, "array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true }, "asn1.js": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", "dev": true, "requires": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" }, "dependencies": { "bn.js": { "version": "4.12.0", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", "dev": true } } }, "assert": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/assert/-/assert-1.5.0.tgz", "integrity": "sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA==", "dev": true, "requires": { "object-assign": "^4.1.1", "util": "0.10.3" }, "dependencies": { "inherits": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", "integrity": "sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA==", "dev": true }, "util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", "integrity": "sha512-5KiHfsmkqacuKjkRkdV7SsfDJ2EGiPsK92s2MhNSY0craxjTdKTtqKsJaCWp4LW33ZZ0OPUv1WO/TFvNQRiQxQ==", "dev": true, "requires": { "inherits": "2.0.1" } } } }, "await-semaphore": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/await-semaphore/-/await-semaphore-0.1.3.tgz", "integrity": "sha512-d1W2aNSYcz/sxYO4pMGX9vq65qOTu0P800epMud+6cYYX0QcT7zyqcxec3VWzpgvdXo57UWmVbZpLMjX2m1I7Q==" }, "axios": { "version": "0.21.4", "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", "requires": { "follow-redirects": "^1.14.0" } }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", "dev": true }, "bn.js": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz", "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==", "dev": true }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "braces": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", "dev": true, "requires": { "fill-range": "^7.0.1" } }, "brfs": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brfs/-/brfs-2.0.2.tgz", "integrity": "sha512-IrFjVtwu4eTJZyu8w/V2gxU7iLTtcHih67sgEdzrhjLBMHp2uYefUBfdM4k2UvcuWMgV7PQDZHSLeNWnLFKWVQ==", "dev": true, "requires": { "quote-stream": "^1.0.1", "resolve": "^1.1.5", "static-module": "^3.0.2", "through2": "^2.0.0" } }, "brorand": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==" }, "browser-pack": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/browser-pack/-/browser-pack-6.1.0.tgz", "integrity": "sha512-erYug8XoqzU3IfcU8fUgyHqyOXqIE4tUTTQ+7mqUjQlvnXkOO6OlT9c/ZoJVHYoAaqGxr09CN53G7XIsO4KtWA==", "dev": true, "requires": { "JSONStream": "^1.0.3", "combine-source-map": "~0.8.0", "defined": "^1.0.0", "safe-buffer": "^5.1.1", "through2": "^2.0.0", "umd": "^3.0.0" } }, "browser-resolve": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/browser-resolve/-/browser-resolve-2.0.0.tgz", "integrity": "sha512-7sWsQlYL2rGLy2IWm8WL8DCTJvYLc/qlOnsakDac87SOoCd16WLsaAMdCiAqsTNHIe+SXfaqyxyo6THoWqs8WQ==", "dev": true, "requires": { "resolve": "^1.17.0" } }, "browser-stdout": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", "dev": true }, "browserify": { "version": "16.5.2", "resolved": "https://registry.npmjs.org/browserify/-/browserify-16.5.2.tgz", "integrity": "sha512-TkOR1cQGdmXU9zW4YukWzWVSJwrxmNdADFbqbE3HFgQWe5wqZmOawqZ7J/8MPCwk/W8yY7Y0h+7mOtcZxLP23g==", "dev": true, "requires": { "JSONStream": "^1.0.3", "assert": "^1.4.0", "browser-pack": "^6.0.1", "browser-resolve": "^2.0.0", "browserify-zlib": "~0.2.0", "buffer": "~5.2.1", "cached-path-relative": "^1.0.0", "concat-stream": "^1.6.0", "console-browserify": "^1.1.0", "constants-browserify": "~1.0.0", "crypto-browserify": "^3.0.0", "defined": "^1.0.0", "deps-sort": "^2.0.0", "domain-browser": "^1.2.0", "duplexer2": "~0.1.2", "events": "^2.0.0", "glob": "^7.1.0", "has": "^1.0.0", "htmlescape": "^1.1.0", "https-browserify": "^1.0.0", "inherits": "~2.0.1", "insert-module-globals": "^7.0.0", "labeled-stream-splicer": "^2.0.0", "mkdirp-classic": "^0.5.2", "module-deps": "^6.2.3", "os-browserify": "~0.3.0", "parents": "^1.0.1", "path-browserify": "~0.0.0", "process": "~0.11.0", "punycode": "^1.3.2", "querystring-es3": "~0.2.0", "read-only-stream": "^2.0.0", "readable-stream": "^2.0.2", "resolve": "^1.1.4", "shasum": "^1.0.0", "shell-quote": "^1.6.1", "stream-browserify": "^2.0.0", "stream-http": "^3.0.0", "string_decoder": "^1.1.1", "subarg": "^1.0.0", "syntax-error": "^1.1.1", "through2": "^2.0.0", "timers-browserify": "^1.0.1", "tty-browserify": "0.0.1", "url": "~0.11.0", "util": "~0.10.1", "vm-browserify": "^1.0.0", "xtend": "^4.0.0" }, "dependencies": { "buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.2.1.tgz", "integrity": "sha512-c+Ko0loDaFfuPWiL02ls9Xd3GO3cPVmUobQ6t3rXNUk304u6hGq+8N/kFi+QEIKhzK3uwolVhLzszmfLmMLnqg==", "dev": true, "requires": { "base64-js": "^1.0.2", "ieee754": "^1.1.4" } } } }, "browserify-aes": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", "dev": true, "requires": { "buffer-xor": "^1.0.3", "cipher-base": "^1.0.0", "create-hash": "^1.1.0", "evp_bytestokey": "^1.0.3", "inherits": "^2.0.1", "safe-buffer": "^5.0.1" } }, "browserify-cipher": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", "dev": true, "requires": { "browserify-aes": "^1.0.4", "browserify-des": "^1.0.0", "evp_bytestokey": "^1.0.0" } }, "browserify-des": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", "dev": true, "requires": { "cipher-base": "^1.0.1", "des.js": "^1.0.0", "inherits": "^2.0.1", "safe-buffer": "^5.1.2" } }, "browserify-rsa": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.0.tgz", "integrity": "sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog==", "dev": true, "requires": { "bn.js": "^5.0.0", "randombytes": "^2.0.1" } }, "browserify-sign": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.1.tgz", "integrity": "sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg==", "dev": true, "requires": { "bn.js": "^5.1.1", "browserify-rsa": "^4.0.1", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", "elliptic": "^6.5.3", "inherits": "^2.0.4", "parse-asn1": "^5.1.5", "readable-stream": "^3.6.0", "safe-buffer": "^5.2.0" }, "dependencies": { "readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "requires": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } } } }, "browserify-zlib": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", "dev": true, "requires": { "pako": "~1.0.5" } }, "buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "requires": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "buffer-equal": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-0.0.1.tgz", "integrity": "sha512-RgSV6InVQ9ODPdLWJ5UAqBqJBOg370Nz6ZQtRzpt6nUjc8v0St97uJ4PYC6NztqIScrAXafKM3mZPMygSe1ggA==", "dev": true }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, "buffer-xor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==", "dev": true }, "bufferutil": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.7.tgz", "integrity": "sha512-kukuqc39WOHtdxtw4UScxF/WVnMFVSQVKhtx3AjZJzhd0RGZZldcrfSEbVsWWe6KNH253574cq5F+wpv0G9pJw==", "dev": true, "requires": { "node-gyp-build": "^4.3.0" } }, "builtin-status-codes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==", "dev": true }, "cached-path-relative": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/cached-path-relative/-/cached-path-relative-1.1.0.tgz", "integrity": "sha512-WF0LihfemtesFcJgO7xfOoOcnWzY/QHR4qeDqV44jPU3HTI54+LnfXK3SA27AVVGCdZFgjjFFaqUA9Jx7dMJZA==", "dev": true }, "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true }, "camel-case": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", "dev": true, "requires": { "pascal-case": "^3.1.2", "tslib": "^2.0.3" } }, "camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true }, "chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, "requires": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "charenc": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", "dev": true }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", "dev": true, "requires": { "anymatch": "~3.1.2", "braces": "~3.0.2", "fsevents": "~2.3.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "dependencies": { "glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "requires": { "is-glob": "^4.0.1" } } } }, "cipher-base": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", "dev": true, "requires": { "inherits": "^2.0.1", "safe-buffer": "^5.0.1" } }, "cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, "requires": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, "requires": { "color-name": "1.1.3" } }, "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true }, "combine-source-map": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/combine-source-map/-/combine-source-map-0.8.0.tgz", "integrity": "sha512-UlxQ9Vw0b/Bt/KYwCFqdEwsQ1eL8d1gibiFb7lxQJFdvTgc2hIZi6ugsg+kyhzhPV+QEpUiEIwInIAIrgoEkrg==", "dev": true, "requires": { "convert-source-map": "~1.1.0", "inline-source-map": "~0.6.0", "lodash.memoize": "~3.0.3", "source-map": "~0.5.3" } }, "comment-parser": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.3.1.tgz", "integrity": "sha512-B52sN2VNghyq5ofvUsqZjmk6YkihBX5vMSChmSK9v4ShjKf3Vk5Xcmgpw4o+iIgtrnM/u5FiMpz9VKb8lpBveA==", "dev": true }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, "concat-stream": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", "dev": true, "requires": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^2.2.2", "typedarray": "^0.0.6" } }, "console-browserify": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==", "dev": true }, "constants-browserify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", "integrity": "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==", "dev": true }, "convert-source-map": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.1.3.tgz", "integrity": "sha512-Y8L5rp6jo+g9VEPgvqNfEopjTR4OTYct8lXlS8iVQdmnjDvbdbzYe9rjtFCB9egC86JoNCU61WRY+ScjkZpnIg==", "dev": true }, "core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true }, "create-ecdh": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==", "dev": true, "requires": { "bn.js": "^4.1.0", "elliptic": "^6.5.3" }, "dependencies": { "bn.js": { "version": "4.12.0", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", "dev": true } } }, "create-hash": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", "dev": true, "requires": { "cipher-base": "^1.0.1", "inherits": "^2.0.1", "md5.js": "^1.3.4", "ripemd160": "^2.0.1", "sha.js": "^2.4.0" } }, "create-hmac": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", "dev": true, "requires": { "cipher-base": "^1.0.3", "create-hash": "^1.1.0", "inherits": "^2.0.1", "ripemd160": "^2.0.0", "safe-buffer": "^5.0.1", "sha.js": "^2.4.8" } }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", "dev": true, "requires": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "crypt": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", "dev": true }, "crypto-browserify": { "version": "3.12.0", "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==", "dev": true, "requires": { "browserify-cipher": "^1.0.0", "browserify-sign": "^4.0.0", "create-ecdh": "^4.0.0", "create-hash": "^1.1.0", "create-hmac": "^1.1.0", "diffie-hellman": "^5.0.0", "inherits": "^2.0.1", "pbkdf2": "^3.0.3", "public-encrypt": "^4.0.0", "randombytes": "^2.0.0", "randomfill": "^1.0.3" } }, "d": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", "dev": true, "requires": { "es5-ext": "^0.10.50", "type": "^1.0.1" } }, "dash-ast": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/dash-ast/-/dash-ast-2.0.1.tgz", "integrity": "sha512-5TXltWJGc+RdnabUGzhRae1TRq6m4gr+3K2wQX0is5/F2yS6MJXJvLyI3ErAnsAXuJoGqvfVD5icRgim07DrxQ==", "dev": true }, "debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "requires": { "ms": "2.1.2" } }, "decamelize": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", "dev": true }, "deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, "defined": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.1.tgz", "integrity": "sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==", "dev": true }, "deps-sort": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/deps-sort/-/deps-sort-2.0.1.tgz", "integrity": "sha512-1orqXQr5po+3KI6kQb9A4jnXT1PBwggGl2d7Sq2xsnOeI9GPcE/tGcF9UiSZtZBM7MukY4cAh7MemS6tZYipfw==", "dev": true, "requires": { "JSONStream": "^1.0.3", "shasum-object": "^1.0.0", "subarg": "^1.0.0", "through2": "^2.0.0" } }, "des.js": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz", "integrity": "sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==", "dev": true, "requires": { "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "detective": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.1.tgz", "integrity": "sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==", "dev": true, "requires": { "acorn-node": "^1.8.2", "defined": "^1.0.0", "minimist": "^1.2.6" } }, "diff": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", "dev": true }, "diffie-hellman": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", "requires": { "bn.js": "^4.1.0", "miller-rabin": "^4.0.0", "randombytes": "^2.0.0" }, "dependencies": { "bn.js": { "version": "4.12.0", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" } } }, "dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", "dev": true, "requires": { "path-type": "^4.0.0" } }, "doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, "requires": { "esutils": "^2.0.2" } }, "domain-browser": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz", "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==", "dev": true }, "duplexer2": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", "dev": true, "requires": { "readable-stream": "^2.0.2" } }, "elliptic": { "version": "6.5.4", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", "dev": true, "requires": { "bn.js": "^4.11.9", "brorand": "^1.1.0", "hash.js": "^1.0.0", "hmac-drbg": "^1.0.1", "inherits": "^2.0.4", "minimalistic-assert": "^1.0.1", "minimalistic-crypto-utils": "^1.0.1" }, "dependencies": { "bn.js": { "version": "4.12.0", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", "dev": true } } }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, "es5-ext": { "version": "0.10.62", "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.62.tgz", "integrity": "sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==", "dev": true, "requires": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", "next-tick": "^1.1.0" } }, "es6-iterator": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", "dev": true, "requires": { "d": "1", "es5-ext": "^0.10.35", "es6-symbol": "^3.1.1" } }, "es6-map": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/es6-map/-/es6-map-0.1.5.tgz", "integrity": "sha512-mz3UqCh0uPCIqsw1SSAkB/p0rOzF/M0V++vyN7JqlPtSW/VsYgQBvVvqMLmfBuyMzTpLnNqi6JmcSizs4jy19A==", "dev": true, "requires": { "d": "1", "es5-ext": "~0.10.14", "es6-iterator": "~2.0.1", "es6-set": "~0.1.5", "es6-symbol": "~3.1.1", "event-emitter": "~0.3.5" } }, "es6-set": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/es6-set/-/es6-set-0.1.6.tgz", "integrity": "sha512-TE3LgGLDIBX332jq3ypv6bcOpkLO0AslAQo7p2VqX/1N46YNsvIWgvjojjSEnWEGWMhr1qUbYeTSir5J6mFHOw==", "dev": true, "requires": { "d": "^1.0.1", "es5-ext": "^0.10.62", "es6-iterator": "~2.0.3", "es6-symbol": "^3.1.3", "event-emitter": "^0.3.5", "type": "^2.7.2" }, "dependencies": { "type": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==", "dev": true } } }, "es6-symbol": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", "dev": true, "requires": { "d": "^1.0.1", "ext": "^1.1.2" } }, "escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", "dev": true }, "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true }, "escodegen": { "version": "1.14.3", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", "dev": true, "requires": { "esprima": "^4.0.1", "estraverse": "^4.2.0", "esutils": "^2.0.2", "optionator": "^0.8.1", "source-map": "~0.6.1" }, "dependencies": { "levn": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", "dev": true, "requires": { "prelude-ls": "~1.1.2", "type-check": "~0.3.2" } }, "optionator": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", "dev": true, "requires": { "deep-is": "~0.1.3", "fast-levenshtein": "~2.0.6", "levn": "~0.3.0", "prelude-ls": "~1.1.2", "type-check": "~0.3.2", "word-wrap": "~1.2.3" } }, "prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", "dev": true }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "optional": true }, "type-check": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", "dev": true, "requires": { "prelude-ls": "~1.1.2" } } } }, "eslint": { "version": "8.36.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.36.0.tgz", "integrity": "sha512-Y956lmS7vDqomxlaaQAHVmeb4tNMp2FWIvU/RnU5BD3IKMD/MJPr76xdyr68P8tV1iNMvN2mRK0yy3c+UjL+bw==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.4.0", "@eslint/eslintrc": "^2.0.1", "@eslint/js": "8.36.0", "@humanwhocodes/config-array": "^0.11.8", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "ajv": "^6.10.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.1.1", "eslint-visitor-keys": "^3.3.0", "espree": "^9.5.0", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", "grapheme-splitter": "^1.0.4", "ignore": "^5.2.0", "import-fresh": "^3.0.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "js-sdsl": "^4.1.4", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.1", "strip-ansi": "^6.0.1", "strip-json-comments": "^3.1.0", "text-table": "^0.2.0" }, "dependencies": { "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "requires": { "color-convert": "^2.0.1" } }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "requires": { "color-name": "~1.1.4" } }, "color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, "escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true }, "eslint-scope": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", "dev": true, "requires": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, "supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "requires": { "has-flag": "^4.0.0" } } } }, "eslint-config-prettier": { "version": "8.7.0", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.7.0.tgz", "integrity": "sha512-HHVXLSlVUhMSmyW4ZzEuvjpwqamgmlfkutD53cYXLikh4pt/modINRcCIApJ84czDxM4GZInwUrromsDdTImTA==", "dev": true }, "eslint-plugin-jsdoc": { "version": "40.0.3", "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-40.0.3.tgz", "integrity": "sha512-4QXkuo4yVFJWsZIvdgFMBs6ZWVGDBZGO06kcgM060/96G8+6Fr7JZWy+Wg0I1C0+qxxfpB+aIepdE29vYxX2kA==", "dev": true, "requires": { "@es-joy/jsdoccomment": "~0.37.0", "comment-parser": "1.3.1", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "esquery": "^1.5.0", "semver": "^7.3.8", "spdx-expression-parse": "^3.0.1" }, "dependencies": { "escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true } } }, "eslint-plugin-prettier": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", "integrity": "sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==", "dev": true, "requires": { "prettier-linter-helpers": "^1.0.0" } }, "eslint-plugin-security": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-1.7.1.tgz", "integrity": "sha512-sMStceig8AFglhhT2LqlU5r+/fn9OwsA72O5bBuQVTssPCdQAOQzL+oMn/ZcpeUY6KcNfLJArgcrsSULNjYYdQ==", "dev": true, "requires": { "safe-regex": "^2.1.1" } }, "eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "requires": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "eslint-visitor-keys": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", "dev": true }, "espree": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.0.tgz", "integrity": "sha512-JPbJGhKc47++oo4JkEoTe2wjy4fmMwvFpgJT9cQzmfXKp22Dr6Hf1tdCteLz1h0P3t+mGvWZ+4Uankvh8+c6zw==", "dev": true, "requires": { "acorn": "^8.8.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.3.0" } }, "esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "dev": true }, "esquery": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", "dev": true, "requires": { "estraverse": "^5.1.0" }, "dependencies": { "estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true } } }, "esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "requires": { "estraverse": "^5.2.0" }, "dependencies": { "estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true } } }, "estraverse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true }, "estree-is-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/estree-is-function/-/estree-is-function-1.0.0.tgz", "integrity": "sha512-nSCWn1jkSq2QAtkaVLJZY2ezwcFO161HVc174zL1KPW3RJ+O6C3eJb8Nx7OXzvhoEv+nLgSR1g71oWUHUDTrJA==", "dev": true }, "esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, "event-emitter": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", "dev": true, "requires": { "d": "1", "es5-ext": "~0.10.14" } }, "events": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/events/-/events-2.1.0.tgz", "integrity": "sha512-3Zmiobend8P9DjmKAty0Era4jV8oJ0yGYe2nJJAxgymF9+N8F2m0hhZiMoWtcfepExzNKZumFU3ksdQbInGWCg==", "dev": true }, "evp_bytestokey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", "dev": true, "requires": { "md5.js": "^1.3.4", "safe-buffer": "^5.1.1" } }, "ext": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", "dev": true, "requires": { "type": "^2.7.2" }, "dependencies": { "type": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==", "dev": true } } }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, "fast-diff": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", "dev": true }, "fast-glob": { "version": "3.2.12", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", "dev": true, "requires": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" }, "dependencies": { "glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "requires": { "is-glob": "^4.0.1" } } } }, "fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true }, "fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, "fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "dev": true }, "fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", "dev": true, "requires": { "reusify": "^1.0.4" } }, "file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, "requires": { "flat-cache": "^3.0.4" } }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", "dev": true, "requires": { "to-regex-range": "^5.0.1" } }, "find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "requires": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "flat": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", "dev": true }, "flat-cache": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", "dev": true, "requires": { "flatted": "^3.1.0", "rimraf": "^3.0.2" } }, "flatted": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "dev": true }, "follow-redirects": { "version": "1.15.2", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, "fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "optional": true }, "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", "dev": true }, "get-assigned-identifiers": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/get-assigned-identifiers/-/get-assigned-identifiers-1.2.0.tgz", "integrity": "sha512-mBBwmeGTrxEMO4pMaaf/uUEFHnYtwr8FTe8Y/mer4rcV/bye0qGm6pw1bGZFGStxC5O76c5ZAVBGnqHmOaJpdQ==", "dev": true }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true }, "glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "dev": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "requires": { "is-glob": "^4.0.3" } }, "globals": { "version": "13.20.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", "dev": true, "requires": { "type-fest": "^0.20.2" } }, "globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", "dev": true, "requires": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "grapheme-splitter": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "dev": true }, "growl": { "version": "1.10.5", "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", "dev": true }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", "dev": true, "requires": { "function-bind": "^1.1.1" } }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true }, "hash-base": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz", "integrity": "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==", "dev": true, "requires": { "inherits": "^2.0.4", "readable-stream": "^3.6.0", "safe-buffer": "^5.2.0" }, "dependencies": { "readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "requires": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } } } }, "hash.js": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", "dev": true, "requires": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true }, "hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", "dev": true, "requires": { "hash.js": "^1.0.3", "minimalistic-assert": "^1.0.0", "minimalistic-crypto-utils": "^1.0.1" } }, "htmlescape": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/htmlescape/-/htmlescape-1.1.1.tgz", "integrity": "sha512-eVcrzgbR4tim7c7soKQKtxa/kQM4TzjnlU83rcZ9bHU6t31ehfV7SktN6McWgwPWg+JYMA/O3qpGxBvFq1z2Jg==", "dev": true }, "https-browserify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==", "dev": true }, "ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" }, "ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", "dev": true }, "import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", "dev": true, "requires": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "dev": true, "requires": { "once": "^1.3.0", "wrappy": "1" } }, "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, "inline-source-map": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/inline-source-map/-/inline-source-map-0.6.2.tgz", "integrity": "sha512-0mVWSSbNDvedDWIN4wxLsdPM4a7cIPcpyMxj3QZ406QRwQ6ePGB1YIHxVPjqpcUGbWQ5C+nHTwGNWAGvt7ggVA==", "dev": true, "requires": { "source-map": "~0.5.3" } }, "insert-module-globals": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/insert-module-globals/-/insert-module-globals-7.2.1.tgz", "integrity": "sha512-ufS5Qq9RZN+Bu899eA9QCAYThY+gGW7oRkmb0vC93Vlyu/CFGcH0OYPEjVkDXA5FEbTt1+VWzdoOD3Ny9N+8tg==", "dev": true, "requires": { "JSONStream": "^1.0.3", "acorn-node": "^1.5.2", "combine-source-map": "^0.8.0", "concat-stream": "^1.6.1", "is-buffer": "^1.1.0", "path-is-absolute": "^1.0.1", "process": "~0.11.0", "through2": "^2.0.0", "undeclared-identifiers": "^1.1.2", "xtend": "^4.0.0" } }, "is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dev": true, "requires": { "binary-extensions": "^2.0.0" } }, "is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", "dev": true }, "is-core-module": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", "dev": true, "requires": { "has": "^1.0.3" } }, "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true }, "is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true }, "is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "requires": { "is-extglob": "^2.1.1" } }, "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true }, "is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true }, "is-plain-obj": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", "dev": true }, "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", "dev": true }, "is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true }, "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, "js-sdsl": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", "dev": true }, "js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, "requires": { "argparse": "^2.0.1" } }, "jsdoc-type-pratt-parser": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.0.0.tgz", "integrity": "sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==", "dev": true }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, "json-stable-stringify": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-0.0.1.tgz", "integrity": "sha512-nKtD/Qxm7tWdZqJoldEC7fF0S41v0mWbeaXG3637stOWfyGxTgWTYE2wtfKmjzpvxv2MA2xzxsXOIiwUpkX6Qw==", "dev": true, "requires": { "jsonify": "~0.0.0" } }, "json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, "jsonify": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", "dev": true }, "jsonparse": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", "dev": true }, "labeled-stream-splicer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/labeled-stream-splicer/-/labeled-stream-splicer-2.0.2.tgz", "integrity": "sha512-Ca4LSXFFZUjPScRaqOcFxneA0VpKZr4MMYCljyQr4LIewTLb3Y0IUTIsnBBsVubIeEfxeSZpSjSsRM8APEQaAw==", "dev": true, "requires": { "inherits": "^2.0.1", "stream-splicer": "^2.0.0" } }, "levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "requires": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "requires": { "p-locate": "^5.0.0" } }, "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, "lodash.memoize": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-3.0.4.tgz", "integrity": "sha512-eDn9kqrAmVUC1wmZvlQ6Uhde44n+tXpqPrN8olQJbttgh0oKclk+SF54P47VEGE9CEiMeRwAP8BaM7UHvBkz2A==", "dev": true }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, "log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "dev": true, "requires": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" }, "dependencies": { "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "requires": { "color-convert": "^2.0.1" } }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "requires": { "color-name": "~1.1.4" } }, "color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, "supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "requires": { "has-flag": "^4.0.0" } } } }, "lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", "dev": true, "requires": { "tslib": "^2.0.3" } }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, "requires": { "yallist": "^4.0.0" } }, "magic-string": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.1.tgz", "integrity": "sha512-sCuTz6pYom8Rlt4ISPFn6wuFodbKMIHUMv4Qko9P17dpxb7s52KJTmRuZZqHdGmLCK9AOcDare039nRIcfdkEg==", "dev": true, "requires": { "sourcemap-codec": "^1.4.1" } }, "md5": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", "dev": true, "requires": { "charenc": "0.0.2", "crypt": "0.0.2", "is-buffer": "~1.1.6" } }, "md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", "dev": true, "requires": { "hash-base": "^3.0.0", "inherits": "^2.0.1", "safe-buffer": "^5.1.2" } }, "merge-source-map": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.0.4.tgz", "integrity": "sha512-PGSmS0kfnTnMJCzJ16BLLCEe6oeYCamKFFdQKshi4BmM6FUwipjVOcBFGxqtQtirtAG4iZvHlqST9CpZKqlRjA==", "dev": true, "requires": { "source-map": "^0.5.6" } }, "merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true }, "micromatch": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", "dev": true, "requires": { "braces": "^3.0.2", "picomatch": "^2.3.1" } }, "miller-rabin": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", "requires": { "bn.js": "^4.0.0", "brorand": "^1.0.1" }, "dependencies": { "bn.js": { "version": "4.12.0", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" } } }, "minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", "dev": true }, "minimalistic-crypto-utils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", "dev": true }, "minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true }, "mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "dev": true }, "mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "dev": true }, "mocha": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/mocha/-/mocha-9.2.2.tgz", "integrity": "sha512-L6XC3EdwT6YrIk0yXpavvLkn8h+EU+Y5UcCHKECyMbdUIxyMuZj4bX4U9e1nvnvUUvQVsV2VHQr5zLdcUkhW/g==", "dev": true, "requires": { "@ungap/promise-all-settled": "1.1.2", "ansi-colors": "4.1.1", "browser-stdout": "1.3.1", "chokidar": "3.5.3", "debug": "4.3.3", "diff": "5.0.0", "escape-string-regexp": "4.0.0", "find-up": "5.0.0", "glob": "7.2.0", "growl": "1.10.5", "he": "1.2.0", "js-yaml": "4.1.0", "log-symbols": "4.1.0", "minimatch": "4.2.1", "ms": "2.1.3", "nanoid": "3.3.1", "serialize-javascript": "6.0.0", "strip-json-comments": "3.1.1", "supports-color": "8.1.1", "which": "2.0.2", "workerpool": "6.2.0", "yargs": "16.2.0", "yargs-parser": "20.2.4", "yargs-unparser": "2.0.0" }, "dependencies": { "cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", "dev": true, "requires": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "debug": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", "dev": true, "requires": { "ms": "2.1.2" }, "dependencies": { "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true } } }, "escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true }, "glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", "dev": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.0.4", "once": "^1.3.0", "path-is-absolute": "^1.0.0" }, "dependencies": { "minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "requires": { "brace-expansion": "^1.1.7" } } } }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, "minimatch": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-4.2.1.tgz", "integrity": "sha512-9Uq1ChtSZO+Mxa/CL1eGizn2vRn3MlLgzhT0Iz8zaY8NdvxvB0d5QdPFmCKf7JKA9Lerx5vRrnwO03jsSfGG9g==", "dev": true, "requires": { "brace-expansion": "^1.1.7" } }, "ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, "supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "requires": { "has-flag": "^4.0.0" } }, "yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", "dev": true, "requires": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } } } }, "mocha-junit-reporter": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/mocha-junit-reporter/-/mocha-junit-reporter-2.2.0.tgz", "integrity": "sha512-W83Ddf94nfLiTBl24aS8IVyFvO8aRDLlCvb+cKb/VEaN5dEbcqu3CXiTe8MQK2DvzS7oKE1RsFTxzN302GGbDQ==", "dev": true, "requires": { "debug": "^4.3.4", "md5": "^2.3.0", "mkdirp": "~1.0.4", "strip-ansi": "^6.0.1", "xml": "^1.0.1" } }, "mocha-multi-reporters": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/mocha-multi-reporters/-/mocha-multi-reporters-1.5.1.tgz", "integrity": "sha512-Yb4QJOaGLIcmB0VY7Wif5AjvLMUFAdV57D2TWEva1Y0kU/3LjKpeRVmlMIfuO1SVbauve459kgtIizADqxMWPg==", "dev": true, "requires": { "debug": "^4.1.1", "lodash": "^4.17.15" } }, "module-deps": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/module-deps/-/module-deps-6.2.3.tgz", "integrity": "sha512-fg7OZaQBcL4/L+AK5f4iVqf9OMbCclXfy/znXRxTVhJSeW5AIlS9AwheYwDaXM3lVW7OBeaeUEY3gbaC6cLlSA==", "dev": true, "requires": { "JSONStream": "^1.0.3", "browser-resolve": "^2.0.0", "cached-path-relative": "^1.0.2", "concat-stream": "~1.6.0", "defined": "^1.0.0", "detective": "^5.2.0", "duplexer2": "^0.1.2", "inherits": "^2.0.1", "parents": "^1.0.0", "readable-stream": "^2.0.2", "resolve": "^1.4.0", "stream-combiner2": "^1.1.1", "subarg": "^1.0.0", "through2": "^2.0.0", "xtend": "^4.0.0" } }, "moment": { "version": "2.29.4", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", "dev": true }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "nanoid": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz", "integrity": "sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==", "dev": true }, "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, "natural-compare-lite": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", "dev": true }, "nerdbank-gitversioning": { "version": "3.5.119", "resolved": "https://registry.npmjs.org/nerdbank-gitversioning/-/nerdbank-gitversioning-3.5.119.tgz", "integrity": "sha512-CQ4QMtl2w9Q0AS9rjrME5FTOls69ILvyuLc7+AwkaFglre4g70zhsf6xpuJvb9su1cBBf3fy5qZt+Mc1bY2npw==", "dev": true, "requires": { "camel-case": "^4.1.2" } }, "next-tick": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", "dev": true }, "no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", "dev": true, "requires": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "node-gyp-build": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.6.0.tgz", "integrity": "sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==", "dev": true }, "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "dev": true }, "object-inspect": { "version": "1.12.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", "dev": true }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, "requires": { "wrappy": "1" } }, "optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", "dev": true, "requires": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.3" } }, "os-browserify": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", "integrity": "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==", "dev": true }, "p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "requires": { "yocto-queue": "^0.1.0" } }, "p-locate": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "requires": { "p-limit": "^3.0.2" } }, "pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "dev": true }, "parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "requires": { "callsites": "^3.0.0" } }, "parents": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parents/-/parents-1.0.1.tgz", "integrity": "sha512-mXKF3xkoUt5td2DoxpLmtOmZvko9VfFpwRwkKDHSNvgmpLAeBo18YDhcPbBzJq+QLCHMbGOfzia2cX4U+0v9Mg==", "dev": true, "requires": { "path-platform": "~0.11.15" } }, "parse-asn1": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.6.tgz", "integrity": "sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw==", "dev": true, "requires": { "asn1.js": "^5.2.0", "browserify-aes": "^1.0.0", "evp_bytestokey": "^1.0.0", "pbkdf2": "^3.0.3", "safe-buffer": "^5.1.1" } }, "pascal-case": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", "dev": true, "requires": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "path-browserify": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz", "integrity": "sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==", "dev": true }, "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true }, "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true }, "path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true }, "path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, "path-platform": { "version": "0.11.15", "resolved": "https://registry.npmjs.org/path-platform/-/path-platform-0.11.15.tgz", "integrity": "sha512-Y30dB6rab1A/nfEKsZxmr01nUotHX0c/ZiIAsCTatEe1CmS5Pm5He7fZ195bPT7RdquoaL8lLxFCMQi/bS7IJg==", "dev": true }, "path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true }, "pbkdf2": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz", "integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==", "dev": true, "requires": { "create-hash": "^1.1.2", "create-hmac": "^1.1.4", "ripemd160": "^2.0.1", "safe-buffer": "^5.0.1", "sha.js": "^2.4.8" } }, "picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true }, "prettier": { "version": "2.8.4", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.4.tgz", "integrity": "sha512-vIS4Rlc2FNh0BySk3Wkd6xmwxB0FpOndW5fisM5H8hsZSxU2VWVB5CWIkIjWvrHjIhxk2g3bfMKM87zNTrZddw==", "dev": true }, "prettier-linter-helpers": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", "dev": true, "requires": { "fast-diff": "^1.1.2" } }, "process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", "dev": true }, "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, "public-encrypt": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", "dev": true, "requires": { "bn.js": "^4.1.0", "browserify-rsa": "^4.0.0", "create-hash": "^1.1.0", "parse-asn1": "^5.0.0", "randombytes": "^2.0.1", "safe-buffer": "^5.1.2" }, "dependencies": { "bn.js": { "version": "4.12.0", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", "dev": true } } }, "punycode": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", "dev": true }, "querystring": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==", "dev": true }, "querystring-es3": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", "integrity": "sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==", "dev": true }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true }, "quote-stream": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/quote-stream/-/quote-stream-1.0.2.tgz", "integrity": "sha512-kKr2uQ2AokadPjvTyKJQad9xELbZwYzWlNfI3Uz2j/ib5u6H9lDP7fUUR//rMycd0gv4Z5P1qXMfXR8YpIxrjQ==", "dev": true, "requires": { "buffer-equal": "0.0.1", "minimist": "^1.1.3", "through2": "^2.0.0" } }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "requires": { "safe-buffer": "^5.1.0" } }, "randomfill": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", "dev": true, "requires": { "randombytes": "^2.0.5", "safe-buffer": "^5.1.0" } }, "read-only-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/read-only-stream/-/read-only-stream-2.0.0.tgz", "integrity": "sha512-3ALe0bjBVZtkdWKIcThYpQCLbBMd/+Tbh2CDSrAIDO3UsZ4Xs+tnyjv2MjCOMMgBG+AsUOeuP1cgtY1INISc8w==", "dev": true, "requires": { "readable-stream": "^2.0.2" } }, "readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "requires": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" }, "dependencies": { "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true }, "string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "requires": { "safe-buffer": "~5.1.0" } } } }, "readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "dev": true, "requires": { "picomatch": "^2.2.1" } }, "regexp-tree": { "version": "0.1.24", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.24.tgz", "integrity": "sha512-s2aEVuLhvnVJW6s/iPgEGK6R+/xngd2jNQ+xy4bXNDKxZKJH6jpPHY6kVeVv1IeLCHgswRj+Kl3ELaDjG6V1iw==", "dev": true }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true }, "resolve": { "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", "dev": true, "requires": { "is-core-module": "^2.9.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" } }, "resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true }, "reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", "dev": true }, "rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "dev": true, "requires": { "glob": "^7.1.3" } }, "ripemd160": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", "dev": true, "requires": { "hash-base": "^3.0.0", "inherits": "^2.0.1" } }, "run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dev": true, "requires": { "queue-microtask": "^1.2.2" } }, "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" }, "safe-regex": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", "dev": true, "requires": { "regexp-tree": "~0.1.1" } }, "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true }, "scope-analyzer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/scope-analyzer/-/scope-analyzer-2.1.2.tgz", "integrity": "sha512-5cfCmsTYV/wPaRIItNxatw02ua/MThdIUNnUOCYp+3LSEJvnG804ANw2VLaavNILIfWXF1D1G2KNANkBBvInwQ==", "dev": true, "requires": { "array-from": "^2.1.1", "dash-ast": "^2.0.1", "es6-map": "^0.1.5", "es6-set": "^0.1.5", "es6-symbol": "^3.1.1", "estree-is-function": "^1.0.0", "get-assigned-identifiers": "^1.1.0" } }, "semver": { "version": "7.3.8", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", "dev": true, "requires": { "lru-cache": "^6.0.0" } }, "serialize-javascript": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", "dev": true, "requires": { "randombytes": "^2.1.0" } }, "sha.js": { "version": "2.4.11", "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", "dev": true, "requires": { "inherits": "^2.0.1", "safe-buffer": "^5.0.1" } }, "shallow-copy": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/shallow-copy/-/shallow-copy-0.0.1.tgz", "integrity": "sha512-b6i4ZpVuUxB9h5gfCxPiusKYkqTMOjEbBs4wMaFbkfia4yFv92UKZ6Df8WXcKbn08JNL/abvg3FnMAOfakDvUw==", "dev": true }, "shasum": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/shasum/-/shasum-1.0.2.tgz", "integrity": "sha512-UTzHm/+AzKfO9RgPgRpDIuMSNie1ubXRaljjlhFMNGYoG7z+rm9AHLPMf70R7887xboDH9Q+5YQbWKObFHEAtw==", "dev": true, "requires": { "json-stable-stringify": "~0.0.0", "sha.js": "~2.4.4" } }, "shasum-object": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/shasum-object/-/shasum-object-1.0.0.tgz", "integrity": "sha512-Iqo5rp/3xVi6M4YheapzZhhGPVs0yZwHj7wvwQ1B9z8H6zk+FEnI7y3Teq7qwnekfEhu8WmG2z0z4iWZaxLWVg==", "dev": true, "requires": { "fast-safe-stringify": "^2.0.7" } }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "requires": { "shebang-regex": "^3.0.0" } }, "shebang-regex": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true }, "shell-quote": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.0.tgz", "integrity": "sha512-QHsz8GgQIGKlRi24yFc6a6lN69Idnx634w49ay6+jA5yFh7a1UY+4Rp6HPx/L/1zcEDPEij8cIsiqR6bQsE5VQ==", "dev": true }, "simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", "dev": true }, "slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true }, "source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "dev": true }, "source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "requires": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" }, "dependencies": { "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true } } }, "sourcemap-codec": { "version": "1.4.8", "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", "dev": true }, "spdx-exceptions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", "dev": true }, "spdx-expression-parse": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "dev": true, "requires": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "spdx-license-ids": { "version": "3.0.13", "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz", "integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==", "dev": true }, "static-eval": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.0.tgz", "integrity": "sha512-agtxZ/kWSsCkI5E4QifRwsaPs0P0JmZV6dkLz6ILYfFYQGn+5plctanRN+IC8dJRiFkyXHrwEE3W9Wmx67uDbw==", "dev": true, "requires": { "escodegen": "^1.11.1" } }, "static-module": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/static-module/-/static-module-3.0.4.tgz", "integrity": "sha512-gb0v0rrgpBkifXCa3yZXxqVmXDVE+ETXj6YlC/jt5VzOnGXR2C15+++eXuMDUYsePnbhf+lwW0pE1UXyOLtGCw==", "dev": true, "requires": { "acorn-node": "^1.3.0", "concat-stream": "~1.6.0", "convert-source-map": "^1.5.1", "duplexer2": "~0.1.4", "escodegen": "^1.11.1", "has": "^1.0.1", "magic-string": "0.25.1", "merge-source-map": "1.0.4", "object-inspect": "^1.6.0", "readable-stream": "~2.3.3", "scope-analyzer": "^2.0.1", "shallow-copy": "~0.0.1", "static-eval": "^2.0.5", "through2": "~2.0.3" }, "dependencies": { "convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "dev": true } } }, "stream-browserify": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz", "integrity": "sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg==", "dev": true, "requires": { "inherits": "~2.0.1", "readable-stream": "^2.0.2" } }, "stream-combiner2": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", "integrity": "sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==", "dev": true, "requires": { "duplexer2": "~0.1.0", "readable-stream": "^2.0.2" } }, "stream-http": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-3.2.0.tgz", "integrity": "sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A==", "dev": true, "requires": { "builtin-status-codes": "^3.0.0", "inherits": "^2.0.4", "readable-stream": "^3.6.0", "xtend": "^4.0.2" }, "dependencies": { "readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "requires": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } } } }, "stream-splicer": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/stream-splicer/-/stream-splicer-2.0.1.tgz", "integrity": "sha512-Xizh4/NPuYSyAXyT7g8IvdJ9HJpxIGL9PjyhtywCZvvP0OPIdqyrr4dMikeuvY8xahpdKEBlBTySe583totajg==", "dev": true, "requires": { "inherits": "^2.0.1", "readable-stream": "^2.0.2" } }, "string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "requires": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "requires": { "safe-buffer": "~5.2.0" } }, "strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "requires": { "ansi-regex": "^5.0.1" } }, "strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true }, "subarg": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/subarg/-/subarg-1.0.0.tgz", "integrity": "sha512-RIrIdRY0X1xojthNcVtgT9sjpOGagEUKpZdgBUi054OEPFo282yg+zE+t1Rj3+RqKq2xStL7uUHhY+AjbC4BXg==", "dev": true, "requires": { "minimist": "^1.1.0" } }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, "requires": { "has-flag": "^3.0.0" } }, "supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true }, "syntax-error": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/syntax-error/-/syntax-error-1.4.0.tgz", "integrity": "sha512-YPPlu67mdnHGTup2A8ff7BC2Pjq0e0Yp/IyTFN03zWO0RcK07uLcbi7C2KpGR2FvWbaB0+bfE27a+sBKebSo7w==", "dev": true, "requires": { "acorn-node": "^1.2.0" } }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "dev": true }, "through2": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", "dev": true, "requires": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" } }, "timers-browserify": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-1.4.2.tgz", "integrity": "sha512-PIxwAupJZiYU4JmVZYwXp9FKsHMXb5h0ZEFyuXTAn8WLHOlcij+FEcbrvDsom1o5dr1YggEtFbECvGCW2sT53Q==", "dev": true, "requires": { "process": "~0.11.0" } }, "tmp": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.1.0.tgz", "integrity": "sha512-J7Z2K08jbGcdA1kkQpJSqLF6T0tdQqpR2pnSUXsIchbPdTI9v3e85cLW0d6WDhwuAleOV71j2xWs8qMPfK7nKw==", "dev": true, "requires": { "rimraf": "^2.6.3" }, "dependencies": { "rimraf": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", "dev": true, "requires": { "glob": "^7.1.3" } } } }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, "requires": { "is-number": "^7.0.0" } }, "tslib": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==", "dev": true }, "tsutils": { "version": "3.21.0", "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", "dev": true, "requires": { "tslib": "^1.8.1" }, "dependencies": { "tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true } } }, "tty-browserify": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz", "integrity": "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==", "dev": true }, "type": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==", "dev": true }, "type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "requires": { "prelude-ls": "^1.2.1" } }, "type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true }, "typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "dev": true }, "typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", "dev": true, "requires": { "is-typedarray": "^1.0.0" } }, "typescript": { "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true }, "umd": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/umd/-/umd-3.0.3.tgz", "integrity": "sha512-4IcGSufhFshvLNcMCV80UnQVlZ5pMOC8mvNPForqwA4+lzYQuetTESLDQkeLmihq8bRcnpbQa48Wb8Lh16/xow==", "dev": true }, "undeclared-identifiers": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/undeclared-identifiers/-/undeclared-identifiers-1.1.3.tgz", "integrity": "sha512-pJOW4nxjlmfwKApE4zvxLScM/njmwj/DiUBv7EabwE4O8kRUy+HIwxQtZLBPll/jx1LJyBcqNfB3/cpv9EZwOw==", "dev": true, "requires": { "acorn-node": "^1.3.0", "dash-ast": "^1.0.0", "get-assigned-identifiers": "^1.2.0", "simple-concat": "^1.0.0", "xtend": "^4.0.1" }, "dependencies": { "dash-ast": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/dash-ast/-/dash-ast-1.0.0.tgz", "integrity": "sha512-Vy4dx7gquTeMcQR/hDkYLGUnwVil6vk4FOOct+djUnHOUWt+zJPJAaRIXaAFkPXtJjvlY7o3rfRu0/3hpnwoUA==", "dev": true } } }, "uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "requires": { "punycode": "^2.1.0" }, "dependencies": { "punycode": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", "dev": true } } }, "url": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", "integrity": "sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ==", "dev": true, "requires": { "punycode": "1.3.2", "querystring": "0.2.0" }, "dependencies": { "punycode": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==", "dev": true } } }, "utf-8-validate": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", "dev": true, "requires": { "node-gyp-build": "^4.3.0" } }, "util": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", "dev": true, "requires": { "inherits": "2.0.3" }, "dependencies": { "inherits": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", "dev": true } } }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, "uuid": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" }, "vm-browserify": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", "dev": true }, "vscode-jsonrpc": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-4.0.0.tgz", "integrity": "sha512-perEnXQdQOJMTDFNv+UF3h1Y0z4iSiaN9jIlb0OqIYgosPCZGYh/MCUlkFtV2668PL69lRDO32hmvL2yiidUYg==" }, "websocket": { "version": "1.0.34", "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.34.tgz", "integrity": "sha512-PRDso2sGwF6kM75QykIesBijKSVceR6jL2G8NGYyq2XrItNC2P5/qL5XeR056GhA+Ly7JMFvJb9I312mJfmqnQ==", "dev": true, "requires": { "bufferutil": "^4.0.1", "debug": "^2.2.0", "es5-ext": "^0.10.50", "typedarray-to-buffer": "^3.1.5", "utf-8-validate": "^5.0.2", "yaeti": "^0.0.6" }, "dependencies": { "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, "requires": { "ms": "2.0.0" } }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true } } }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "requires": { "isexe": "^2.0.0" } }, "word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", "dev": true }, "workerpool": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.0.tgz", "integrity": "sha512-Rsk5qQHJ9eowMH28Jwhe8HEbmdYDX4lwoMWshiCXugjtHqMD9ZbiqSDLxcsfdqsETPzVUtX5s1Z5kStiIM6l4A==", "dev": true }, "wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "requires": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" }, "dependencies": { "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "requires": { "color-convert": "^2.0.1" } }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "requires": { "color-name": "~1.1.4" } }, "color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true } } }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, "xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", "dev": true }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "dev": true }, "y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true }, "yaeti": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", "dev": true }, "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, "yargs": { "version": "17.7.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.1.tgz", "integrity": "sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw==", "dev": true, "requires": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" }, "dependencies": { "yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true } } }, "yargs-parser": { "version": "20.2.4", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", "dev": true }, "yargs-unparser": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", "dev": true, "requires": { "camelcase": "^6.0.0", "decamelize": "^4.0.0", "flat": "^5.0.2", "is-plain-obj": "^2.1.0" } }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true } } } dev-tunnels-0.0.25/ts/package.json000066400000000000000000000052561450757157500170060ustar00rootroot00000000000000{ "name": "@microsoft/dev-tunnels", "description": "Tunnels library", "keywords": [ "Tunnels" ], "author": "Microsoft", "license": "MIT", "main": "out/lib/tunnels/index.js", "scripts": { "build": "node ./build.js build", "pack": "node ./build.js pack", "test": "node ./build.js test", "publish": "node ./build.js publish-ts", "compile": "tsc --build", "eslint": "eslint . --ext ts", "eslint-fix": "eslint . --ext ts --fix", "watch": "tsc --build --watch", "test-api": "@powershell copy './test/ts/tunnels-test/starthost.ps1' 'out/lib/tunnels-test/' && cd out/lib/tunnels-test/ && node connection.js sshd -w -p 9880", "mocha": "mocha", "build-pack-publish": "npm run build && npm run pack && npm run publish" }, "dependencies": { "@microsoft/dev-tunnels-ssh": "^3.11.31", "@microsoft/dev-tunnels-ssh-tcp": "^3.11.31", "await-semaphore": "^0.1.3", "axios": "^0.21.1", "buffer": "^5.2.1", "debug": "^4.1.1", "uuid": "^3.3.3", "vscode-jsonrpc": "^4.0.0" }, "devDependencies": { "@testdeck/mocha": "^0.3.3", "@types/debug": "^4.1.4", "@types/mocha": "^5.2.6", "@types/node": "^18.15.2", "@types/tmp": "0.0.34", "@types/uuid": "^3.3.3", "@types/websocket": "0.0.40", "@types/yargs": "^17.0.3", "@typescript-eslint/eslint-plugin": "^5.55.0", "@typescript-eslint/parser": "^5.55.0", "mocha-junit-reporter": "^2.0.2", "brfs": "^2.0.2", "browserify": "^16.2.3", "chalk": "^2.4.2", "eslint": "^8.36.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-jsdoc": "^40.0.1", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-security": "^1.7.1", "mocha": "^9.2.2", "mocha-multi-reporters": "^1.1.7", "moment": "^2.29.4", "nerdbank-gitversioning": "^3.1.91", "prettier": "^2.8.4", "source-map-support": "^0.5.11", "tmp": "^0.1.0", "typescript": "^4.9.5", "websocket": "^1.0.28", "yargs": "^17.2.1" }, "mocha": { "require": "source-map-support/register", "spec": [ "out/lib/tunnels-test/*Tests.js" ] }, "eslintConfig": { "root": true, "env": { "node": true }, "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": 2019, "sourceType": "module", "project": "./tsconfig.eslint.json" }, "extends": [ "prettier" ], "plugins": [ "@typescript-eslint/tslint", "prettier" ], "rules": { "prettier/prettier": "error", "@typescript-eslint/tslint/config": [ 2, { "lintFile": "./tslint.json" } ] } }, "eslintIgnore": [ "bench", "out", "test" ], "prettier": { "printWidth": 100, "useTabs": false, "tabWidth": 4, "semi": true, "singleQuote": true, "trailingComma": "all", "arrowParens": "always", "parser": "typescript" } } dev-tunnels-0.0.25/ts/src/000077500000000000000000000000001450757157500152775ustar00rootroot00000000000000dev-tunnels-0.0.25/ts/src/connections/000077500000000000000000000000001450757157500176215ustar00rootroot00000000000000dev-tunnels-0.0.25/ts/src/connections/LICENSE000066400000000000000000000021651450757157500206320ustar00rootroot00000000000000 MIT License Copyright (c) Microsoft Corporation. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE dev-tunnels-0.0.25/ts/src/connections/README.md000066400000000000000000000001171450757157500210770ustar00rootroot00000000000000# Visual Studio Tunnels Contracts Library Tunnels connections library for node dev-tunnels-0.0.25/ts/src/connections/connectionStatus.ts000066400000000000000000000022111450757157500235300ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. /** * Tunnel client or host connection status. */ export enum ConnectionStatus { /** * The connection has not started yet. This is the initial status. */ None = 'none', /** * Connecting (if changed from None) or reconnecting (if changed from Connected) to the tunnel. */ Connecting = 'connecting', /** * Connecting and refreshing the tunnel access token to connect with. */ RefreshingTunnelAccessToken = 'refreshingTunnelAccessToken', /** * Connected to the tunnel. */ Connected = 'connected', /** * Disconnected from the tunnel and could not reconnect either due to disposal, service down, tunnel deleted, or token expiration. This is the final status. */ Disconnected = 'disconnected', /** * Refreshing tunnel host public key. * This may happen when a client is connecting to a tunnel with a stale host public key. SDK client will try to fetch a fresh tunnel from the tunnelManagementClient. */ RefreshingTunnelHostPublicKey = 'refreshingTunnelHostPublicKey', } dev-tunnels-0.0.25/ts/src/connections/connectionStatusChangedEventArgs.ts000066400000000000000000000014161450757157500266270ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { ConnectionStatus } from './connectionStatus'; /** * Connection status changed event args. */ export class ConnectionStatusChangedEventArgs { /** * Creates a new instance of ConnectionStatusChangedEventArgs. */ public constructor( /** * Gets the previous connection status. */ public readonly previousStatus: ConnectionStatus, /** * Gets the current connection status. */ public readonly status: ConnectionStatus, /** * Gets the error that caused disconnect if {@link status} is {@link ConnectionStatus.Disconnected}. */ public readonly disconnectError?: Error, ) {} } dev-tunnels-0.0.25/ts/src/connections/defaultTunnelRelayStreamFactory.ts000066400000000000000000000026031450757157500265050ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { Stream } from '@microsoft/dev-tunnels-ssh'; import { TunnelRelayStreamFactory } from './tunnelRelayStreamFactory'; import { isNode, SshHelpers } from './sshHelpers'; import { IClientConfig } from 'websocket'; /** * Default factory for creating streams to a tunnel relay. */ export class DefaultTunnelRelayStreamFactory implements TunnelRelayStreamFactory { public async createRelayStream( relayUri: string, protocols: string[], accessToken?: string, clientConfig?: IClientConfig, ): Promise<{ stream: Stream, protocol: string }> { if (isNode()) { const stream = await SshHelpers.openConnection( relayUri, protocols, { ...(accessToken && { Authorization: `tunnel ${accessToken}` }), }, clientConfig, ); return { stream, protocol: stream.protocol! }; } else { // Web sockets don't support auth. Authenticate TunnelRelay by sending accessToken as a subprotocol. if (accessToken) { protocols = [...protocols, accessToken]; } const stream = await SshHelpers.openConnection(relayUri, protocols); return { stream, protocol: stream.protocol! }; } } } dev-tunnels-0.0.25/ts/src/connections/index.ts000066400000000000000000000016611450757157500213040ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. export * from './tunnelClient'; export * from './tunnelHost'; export * from './multiModeTunnelClient'; export * from './multiModeTunnelHost'; export * from './retryTcpListenerFactory'; export * from './sessionPortKey'; export * from './sshHelpers'; export * from './tunnelClient'; export * from './tunnelClientBase'; export * from './tunnelHost'; export * from './tunnelHostBase'; export * from './tunnelRelayStreamFactory'; export * from './defaultTunnelRelayStreamFactory'; export * from './tunnelRelayTunnelClient'; export * from './tunnelRelayTunnelHost'; export * from './tunnelConnection'; export * from './tunnelConnectionBase'; export * from './connectionStatus'; export * from './connectionStatusChangedEventArgs'; export * from './refreshingTunnelAccessTokenEventArgs'; export * from './retryingTunnelConnectionEventArgs'; export * from './tunnelConnector'; dev-tunnels-0.0.25/ts/src/connections/messages/000077500000000000000000000000001450757157500214305ustar00rootroot00000000000000dev-tunnels-0.0.25/ts/src/connections/messages/portRelayConnectRequestMessage.ts000066400000000000000000000030671450757157500301570ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { SshDataReader, SshDataWriter } from "@microsoft/dev-tunnels-ssh"; import { PortForwardChannelOpenMessage } from "@microsoft/dev-tunnels-ssh-tcp"; /** * Extends port-forward channel open messages to include additional properties required * by the tunnel relay. */ export class PortRelayConnectRequestMessage extends PortForwardChannelOpenMessage { /** * Access token with 'connect' scope used to authorize the port connection request. * A long-running client may need handle the `RefreshingTunnelAccessToken` event to refresh * the access token before opening additional connections (channels) to forwarded ports. */ public accessToken?: string; /** * Gets or sets a value indicating whether end-to-end encryption is requested for the * connection. * The tunnel relay or tunnel host may enable E2E encryption or not depending on capabilities * and policies. The channel open response will indicate whether E2E encryption is actually * enabled for the connection. */ public isE2EEncryptionRequested: boolean = false; protected onWrite(writer: SshDataWriter): void { super.onWrite(writer); writer.writeString(this.accessToken ?? '', 'utf8'); writer.writeBoolean(this.isE2EEncryptionRequested); } protected onRead(reader: SshDataReader): void { super.onRead(reader); this.accessToken = reader.readString('utf8'); this.isE2EEncryptionRequested = reader.readBoolean(); } } dev-tunnels-0.0.25/ts/src/connections/messages/portRelayConnectResponseMessage.ts000066400000000000000000000021741450757157500303230ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { ChannelOpenConfirmationMessage, SshDataReader, SshDataWriter } from "@microsoft/dev-tunnels-ssh"; /** * Extends port-forward channel open confirmation messages to include additional properties * required by the tunnel relay. */ export class PortRelayConnectResponseMessage extends ChannelOpenConfirmationMessage { /** * Gets or sets a value indicating whether end-to-end encryption is enabled for the * connection. * The tunnel client may request E2E encryption via `isE2EEncryptionRequested`. Then relay * or host may enable E2E encryption or not depending on capabilities and policies, and the * resulting enabled status is returned to the client via this property. */ public isE2EEncryptionEnabled: boolean = false; protected onWrite(writer: SshDataWriter): void { super.onWrite(writer); writer.writeBoolean(this.isE2EEncryptionEnabled); } protected onRead(reader: SshDataReader): void { super.onRead(reader); this.isE2EEncryptionEnabled = reader.readBoolean(); } } dev-tunnels-0.0.25/ts/src/connections/messages/portRelayRequestMessage.ts000066400000000000000000000021151450757157500266360ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { SshDataReader, SshDataWriter } from "@microsoft/dev-tunnels-ssh"; import { PortForwardRequestMessage } from "@microsoft/dev-tunnels-ssh-tcp"; /** * Extends port-forward request messagse to include additional properties required * by the tunnel relay. */ export class PortRelayRequestMessage extends PortForwardRequestMessage { /** * Access token with 'host' scope used to authorize the port-forward request. * A long-running host may need to handle the `refreshingTunnelAccessToken` event to * refresh the access token before forwarding additional ports. */ public accessToken?: string; protected onWrite(writer: SshDataWriter): void { super.onWrite(writer); if (!this.accessToken) { throw new Error("An access token is required."); } writer.writeString(this.accessToken, 'utf8'); } protected onRead(reader: SshDataReader): void { super.onRead(reader); this.accessToken = reader.readString('utf8'); } } dev-tunnels-0.0.25/ts/src/connections/multiModeTunnelClient.ts000066400000000000000000000052761450757157500244670ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { TunnelConnectionMode, Tunnel, TunnelAccessScopes } from '@microsoft/dev-tunnels-contracts'; import { CancellationToken, SshStream } from '@microsoft/dev-tunnels-ssh'; import { ForwardedPortsCollection } from '@microsoft/dev-tunnels-ssh-tcp'; import { TunnelClient } from '.'; import { TunnelConnectionBase } from './tunnelConnectionBase'; import { TunnelConnectionOptions } from './tunnelConnectionOptions'; /** * Tunnel client implementation that selects one of multiple available connection modes. */ export class MultiModeTunnelClient extends TunnelConnectionBase implements TunnelClient { public forwardedPorts: ForwardedPortsCollection | undefined; public clients: TunnelClient[] = []; public connectionModes: TunnelConnectionMode[] = this.clients ? [...new Set(...this.clients.map((c) => c.connectionModes))] : []; public constructor() { super(TunnelAccessScopes.Connect); } /** * A value indicating whether local connections for forwarded ports are accepted. * Local connections are not accepted if the host process is not NodeJS (e.g. browser). */ public get acceptLocalConnectionsForForwardedPorts(): boolean { return !!this.clients.find((c) => c.acceptLocalConnectionsForForwardedPorts); } public set acceptLocalConnectionsForForwardedPorts(value: boolean) { this.clients.forEach((c) => (c.acceptLocalConnectionsForForwardedPorts = value)); } public get localForwardingHostAddress(): string { return this.clients[0]?.localForwardingHostAddress; } public set localForwardingHostAddress(value: string) { this.clients.forEach((c) => (c.localForwardingHostAddress = value)); } public connect( tunnel: Tunnel, options?: TunnelConnectionOptions, cancellation?: CancellationToken, ): Promise { if (!tunnel) { throw new Error('Tunnel cannot be null'); } return new Promise((resolve) => {}); } public connectToForwardedPort( fowardedPort: number, cancellation?: CancellationToken, ): Promise { throw new Error('Method not implemented.'); } public waitForForwardedPort( forwardedPort: number, cancellation?: CancellationToken, ): Promise { throw new Error('Method not implemented.'); } public async refreshPorts(): Promise { throw new Error('Method not implemented.'); } public async dispose(): Promise { await super.dispose(); await Promise.all(this.clients.map((client) => client.dispose())); } } dev-tunnels-0.0.25/ts/src/connections/multiModeTunnelHost.ts000066400000000000000000000032211450757157500241520ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { CancellationToken } from '@microsoft/dev-tunnels-ssh'; import { Tunnel, TunnelAccessScopes, TunnelPort } from '@microsoft/dev-tunnels-contracts'; import { TunnelHost } from '.'; import { v4 as uuidv4 } from 'uuid'; import { TunnelConnectionBase } from './tunnelConnectionBase'; import { TunnelConnectionOptions } from './tunnelConnectionOptions'; /** * Aggregation of multiple tunnel hosts. */ export class MultiModeTunnelHost extends TunnelConnectionBase implements TunnelHost { public static hostId: string = uuidv4(); public hosts: TunnelHost[]; public constructor() { super(TunnelAccessScopes.Host); this.hosts = []; } /** * @deprecated Use `connect()` instead. */ public async start(tunnel: Tunnel): Promise { await this.connect(tunnel); } public async connect( tunnel: Tunnel, options?: TunnelConnectionOptions, cancellation?: CancellationToken): Promise { const startTasks: Promise[] = []; this.hosts.forEach((host) => { startTasks.push(host.connect(tunnel, options, cancellation)); }); await Promise.all(startTasks); } public async refreshPorts(): Promise { const refreshTasks: Promise[] = []; this.hosts.forEach((host) => { refreshTasks.push(host.refreshPorts()); }); await Promise.all(refreshTasks); } public async dispose(): Promise { await Promise.all(this.hosts.map((host) => host.dispose())); await super.dispose(); } } dev-tunnels-0.0.25/ts/src/connections/package.json000066400000000000000000000014421450757157500221100ustar00rootroot00000000000000{ "name": "@microsoft/dev-tunnels-connections", "version": "", "description": "Tunnels library for Visual Studio tools", "keywords": [ "Tunnels" ], "author": "Microsoft", "license": "MIT", "scripts": { "compile": "npm run -C ../.. compile", "eslint": "npm run -C ../.. eslint", "eslint-fix": "npm run -C ../.. eslint-fix", "watch": "npm run -C ../.. watch .", "test": "npm run -C ../.. test" }, "dependencies": { "buffer": "^5.2.1", "debug": "^4.1.1", "vscode-jsonrpc": "^4.0.0", "@microsoft/dev-tunnels-contracts": "^1.0.0", "@microsoft/dev-tunnels-management": "^1.0.0", "@microsoft/dev-tunnels-ssh": "^3.11.31", "@microsoft/dev-tunnels-ssh-tcp": "^3.11.31", "uuid": "^3.3.3", "await-semaphore": "^0.1.3", "websocket": "^1.0.28", "es5-ext": "0.10.53" } } dev-tunnels-0.0.25/ts/src/connections/refreshingTunnelAccessTokenEventArgs.ts000066400000000000000000000017541450757157500274640ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { CancellationToken, SshDisconnectReason, Stream, Trace } from '@microsoft/dev-tunnels-ssh'; /** * Event args for tunnel access token refresh event. */ export class RefreshingTunnelAccessTokenEventArgs { /** * Creates a new instance of RefreshingTunnelAccessTokenEventArgs class. */ public constructor( /** * Tunnel access scope to get the token for. */ public readonly tunnelAccessScope: string, /** * Cancellation token that event handler may observe when it asynchronously fetches the tunnel access token. */ public readonly cancellation: CancellationToken, ) {} /** * Token promise the event handler may set to asynchnronously fetch the token. * The result of the promise may be a new tunnel access token or null if it couldn't get the token. */ public tunnelAccessToken?: Promise; } dev-tunnels-0.0.25/ts/src/connections/relayTunnelConnector.ts000066400000000000000000000274441450757157500243610ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { CancellationError, CancellationToken, SshConnectionError, SshDisconnectReason, SshReconnectError, Stream, Trace, TraceLevel, } from '@microsoft/dev-tunnels-ssh'; import { TunnelConnector } from './tunnelConnector'; import { delay, getErrorMessage } from './utils'; import { BrowserWebSocketRelayError, RelayConnectionError } from './sshHelpers'; import { RetryingTunnelConnectionEventArgs } from './retryingTunnelConnectionEventArgs'; import { TunnelSession } from './tunnelSession'; import * as http from 'http'; import { TunnelConnectionOptions } from './tunnelConnectionOptions'; // After the 6th attemt, each next attempt will happen after a delay of 2^7 * 100ms = 12.8s const maxReconnectDelayMs = 13000; // Delay between the 1st and the 2nd attempts. const reconnectInitialDelayMs = 100; // There is no status code information in web socket errors in browser context. // Instead, connection will retry anyway with limited retry attempts. const maxBrowserReconnectAttempts = 5; /** * Tunnel connector that connects a tunnel session to a web socket stream in the tunnel Relay service. */ export class RelayTunnelConnector implements TunnelConnector { public constructor(private readonly tunnelSession: TunnelSession) {} private get trace(): Trace { return this.tunnelSession.trace; } /** * Connect or reconnect tunnel SSH session. * @param isReconnect A value indicating if this is a reconnect (true) or regular connect (false). * @param cancellation Cancellation token. */ public async connectSession( isReconnect: boolean, options?: TunnelConnectionOptions, cancellation?: CancellationToken, ): Promise { let disconnectReason: SshDisconnectReason | undefined; let error: Error | undefined; function throwIfCancellation(e: any) { if (e instanceof CancellationError && cancellation?.isCancellationRequested) { error = undefined; disconnectReason = SshDisconnectReason.byApplication; throw e; } } function throwError(message: string) { if (error) { // Preserve the error object, just replace the message. error.message = message; } else { error = new Error(message); } throw error; } let browserReconnectAttempt = 0; let attemptDelayMs: number = reconnectInitialDelayMs; let isTunnelAccessTokenRefreshed = false; let isDelayNeeded = true; let errorDescription: string | undefined; for (let attempt = 0; ; attempt++) { if (cancellation?.isCancellationRequested) { throw new CancellationError(); } if (attempt > 0) { if (error) { if (!(options?.enableRetry ?? true)) { throw error; } const args = new RetryingTunnelConnectionEventArgs(error, attemptDelayMs); this.tunnelSession.onRetrying(args); if (!args.retry) { // Stop retries. throw error; } if (args.delayMs >= reconnectInitialDelayMs) { attemptDelayMs = args.delayMs; } else { isDelayNeeded = false; } } const retryTiming = isDelayNeeded ? ` in ${ attemptDelayMs < 1000 ? `0.${attemptDelayMs / 100}s` : `${attemptDelayMs / 1000}s` }` : ''; this.trace( TraceLevel.Verbose, 0, `Error connecting to tunnel SSH session, retrying${retryTiming}${ errorDescription ? `: ${errorDescription}` : '' }`, ); if (isDelayNeeded) { await delay(attemptDelayMs, cancellation); if (attemptDelayMs < maxReconnectDelayMs) { attemptDelayMs = attemptDelayMs << 1; } } } isDelayNeeded = true; let stream: Stream | undefined = undefined; errorDescription = undefined; disconnectReason = SshDisconnectReason.connectionLost; error = undefined; try { const streamAndProtocol = await this.tunnelSession.createSessionStream( options, cancellation); stream = streamAndProtocol.stream; await this.tunnelSession.configureSession( stream, streamAndProtocol.protocol, isReconnect, cancellation); stream = undefined; disconnectReason = undefined; return; } catch (e) { if (!(e instanceof Error)) { // Not recoverable if we cannot recognize the error object. throwError( `Failed to connect to the tunnel service and start tunnel SSH session: ${e}`, ); } throwIfCancellation(e); error = e; errorDescription = error.message; // Browser web socket relay error - retry until max number of attempts is exceeded. if (e instanceof BrowserWebSocketRelayError) { if (browserReconnectAttempt++ >= maxBrowserReconnectAttempts) { throw e; } continue; } // SSH reconnection error. Disable reconnection and try again without delay. if (e instanceof SshReconnectError) { disconnectReason = SshDisconnectReason.protocolError; isDelayNeeded = false; isReconnect = false; continue; } // SSH connection error. Only 'connection lost' is recoverable. if (e instanceof SshConnectionError) { const reason = (e as SshConnectionError).reason; if (reason === SshDisconnectReason.connectionLost) { continue; } disconnectReason = reason || SshDisconnectReason.byApplication; throwError(`Failed to start tunnel SSH session: ${errorDescription}`); } // Web socket connection error if (e instanceof RelayConnectionError) { const statusCode = (e as RelayConnectionError).errorContext?.statusCode; const statusCodeText = statusCode ? ` (${statusCode})` : ''; switch (errorDescription) { case 'error.relayClientUnauthorized': { const notAuthorizedText = 'Not authorized' + statusCodeText; if (isTunnelAccessTokenRefreshed) { // We've already refreshed the tunnel access token once. throwError( `${notAuthorizedText}. Refreshed tunnel access token also does not work.`, ); } try { isTunnelAccessTokenRefreshed = await this.tunnelSession.refreshTunnelAccessToken( cancellation, ); } catch (refreshError) { throwIfCancellation(refreshError); throwError( `${notAuthorizedText}. Refreshing tunnel access token failed with error ${getErrorMessage( refreshError, )}`, ); } if (!isTunnelAccessTokenRefreshed) { throwError( `${notAuthorizedText}. Provide a fresh tunnel access token with '${this.tunnelSession.tunnelAccessScope}' scope.`, ); } isDelayNeeded = false; errorDescription = 'The tunnel access token was no longer valid and had just been refreshed.'; continue; } case 'error.relayClientForbidden': throwError( `Forbidden${statusCodeText}. Provide a fresh tunnel access token with '${this.tunnelSession.tunnelAccessScope}' scope.`, ); break; case 'error.tunnelPortNotFound': throwError(`The tunnel or port is not found${statusCodeText}`); break; case 'error.serviceUnavailable': // Normally nginx choses another healthy pod when it encounters 503. // However, if there are no other pods, it returns 503 to the client. // This rare case may happen when the cluster recovers from a failure // and the nginx controller has started but Relay service has not yet. errorDescription = `Service temporarily unavailable${statusCodeText}`; continue; case 'error.tooManyRequests': errorDescription = `Rate limit exceeded${statusCodeText}. Too many requests in a given amount of time.`; if (attempt > 3) { throwError(errorDescription); } if (attemptDelayMs < maxReconnectDelayMs) { attemptDelayMs = attemptDelayMs << 1; } continue; default: if (errorDescription?.startsWith('error.relayConnectionError ')) { const recoverableError = recoverableNetworkErrors.find((s) => errorDescription!.includes(s), ); if (recoverableError) { errorDescription = `Failed to connect to Relay server: ${recoverableError}`; continue; } } } } // Everything else is not recoverable throw e; } finally { // Graft SSH disconnect reason on to the error object as 'reason' property. if (error && disconnectReason && !(error).reason) { (error).reason = disconnectReason; } if (disconnectReason) { await this.tunnelSession.closeSession(error); } if (stream) { await stream.close(error); } } } } } const recoverableNetworkErrors = [ 'ECONNRESET', 'ENOTFOUND', 'ESOCKETTIMEDOUT', 'ETIMEDOUT', 'ECONNREFUSED', 'EHOSTUNREACH', 'EPIPE', 'EAI_AGAIN', 'EBUSY', ]; dev-tunnels-0.0.25/ts/src/connections/retryTcpListenerFactory.ts000066400000000000000000000047371450757157500250560ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { TcpListenerFactory } from '@microsoft/dev-tunnels-ssh-tcp/tcpListenerFactory'; import { Server } from 'net'; import { CancellationToken } from 'vscode-jsonrpc'; import * as net from 'net'; /** * Implementation of a TCP listener factory that retries forwarding with nearby ports and falls back to a random port. * We make the assumption that the remote port that is being connected to and localPort numbers are the same. */ export class RetryTcpListenerFactory implements TcpListenerFactory { public constructor(public readonly localAddress: string) {} public async createTcpListener( localIPAddress: string, localPort: number, canChangePort: boolean, cancellation?: CancellationToken, ): Promise { // The SSH protocol may specify a local IP address for forwarding, but that is ignored // by tunnels. Instead, the tunnel client can specify the local IP address. if (localIPAddress.indexOf(':') >= 0) { // Convert special local address values from IPv4 to IPv6. if (this.localAddress === '0.0.0.0') { localIPAddress = '::'; } else if (this.localAddress === '127.0.0.1') { localIPAddress = '::1'; } } else { // IPv4 localIPAddress = this.localAddress; } const maxOffet = 10; const listener = net.createServer(); for (let offset = 0; ; offset++) { // After reaching the max offset, pass 0 to pick a random available port. const localPortNumber = offset === maxOffet ? 0 : localPort + offset; try { return await new Promise((resolve, reject) => { listener.listen({ host: localIPAddress, port: localPortNumber, ipv6Only: net.isIPv6(localIPAddress), }); listener.on('listening', () => { resolve(listener); }); listener.on('error', (err) => { reject(err); }); }); } catch (err) { console.log('Listening on port ' + localPortNumber + ' failed: ' + err); console.log('Incrementing port and trying again'); continue; } } } } dev-tunnels-0.0.25/ts/src/connections/retryingTunnelConnectionEventArgs.ts000066400000000000000000000012371450757157500270640ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. /** * Event args for tunnel connection retry event. */ export class RetryingTunnelConnectionEventArgs { public constructor( /** * Gets the error that caused the retry. */ public readonly error: Error, /** * Gets the amount of time to wait before retrying. An event handler may change this value. */ public delayMs: number, ) {} /** * Gets or sets a value indicating whether the retry will proceed. An event handler may * set this to false to stop retrying. */ public retry: boolean = true; } dev-tunnels-0.0.25/ts/src/connections/sessionPortKey.ts000066400000000000000000000016641450757157500232010ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. /** * Class for comparing equality in sessionId port pairs */ export class SessionPortKey { /** * Session ID of the client SSH session, or null if the session does not have an ID * (because it is not encrypted and not client-specific). */ public sessionId: Buffer | null; /** * Forwarded port number */ public port: number; public constructor(sessionId: Buffer | null, port: number) { this.sessionId = sessionId ?? null; this.port = port; } public equals(other: SessionPortKey) { return this.port === other.port && ((!this.sessionId && !other.sessionId) || this.sessionId && other.sessionId && this.sessionId === other.sessionId); } public toString() { return this.port + (this.sessionId ? '_' + this.sessionId.toString('base64') : ''); } } dev-tunnels-0.0.25/ts/src/connections/sshHelpers.ts000066400000000000000000000264061450757157500223210ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import * as ssh from '@microsoft/dev-tunnels-ssh'; import { IncomingMessage } from 'http'; import { client as WebSocketClient, connection as WebSocketConnection, IClientConfig, } from 'websocket'; declare module 'websocket' { interface client { /** * 'httpResponse' event in WebSocketClient is fired when the server responds but the HTTP request doesn't properly upgrade to a web socket, * i.e. the status code is not 101 `Switching Protocols`. The argument of the event callback is the recieved response. */ on(event: 'httpResponse', cb: (response: IncomingMessage) => void): this; } } /** * Error class for errors connecting to a web socket in non-node (browser) context. * There is no status code or underlying network error info in the browser context. */ export class BrowserWebSocketRelayError extends Error { public constructor(message?: string) { super(message); } } /** * Ssh connection helper */ export class SshHelpers { /** * Open a connection to the relay uri depending on the running environment. * @param relayUri * @param protocols * @param headers * @param clientConfig * @returns */ public static openConnection( relayUri: string, protocols?: string[], headers?: object, clientConfig?: IClientConfig, ): Promise { if (isNode()) { return SshHelpers.nodeSshStreamFactory(relayUri, protocols, headers, clientConfig); } return SshHelpers.webSshStreamFactory(new WebSocket(relayUri, protocols)); } /** * Creates a client SSH session with standard configuration for tunnels. * @param configure Optional callback for additional session configuration. * @returns The created SSH session. */ public static createSshClientSession( configure?: (config: ssh.SshSessionConfiguration) => void, ): ssh.SshClientSession { return SshHelpers.createSshSession((config) => { if (configure) configure(config); return new ssh.SshClientSession(config); }); } /** * Creates a SSH server session with standard configuration for tunnels. * @param reconnectableSessions Optional list that tracks reconnectable sessions. * @param configure Optional callback for additional session configuration. * @returns The created SSH session. */ public static createSshServerSession( reconnectableSessions?: ssh.SshServerSession[], configure?: (config: ssh.SshSessionConfiguration) => void, ): ssh.SshServerSession { return SshHelpers.createSshSession((config) => { if (configure) configure(config); return new ssh.SshServerSession(config, reconnectableSessions); }); } /** * Create a websocketStream from a connection. * @param connection * @returns */ public static createWebSocketStreamAdapter(connection: WebSocketConnection) { return new ssh.WebSocketStream(new WebsocketStreamAdapter(connection)); } /** * Set up a web Ssh stream factory. * @param socket * @returns */ public static webSshStreamFactory(socket: WebSocket): Promise { socket.binaryType = 'arraybuffer'; return new Promise((resolve, reject) => { socket.onopen = () => { resolve(new ssh.WebSocketStream(socket)); }; socket.onerror = (e) => { // Note: as per web socket guidance https://websockets.spec.whatwg.org/#eventdef-websocket-error, // the user agents must not convey extended error information including the cases where the server // didn't complete the opening handshake (e.g. because it was not a WebSocket server). // So we cannot obtain the response status code. reject(new BrowserWebSocketRelayError(`Failed to connect to relay url`)); }; }); } private static createSshSession( factoryCallback: (config: ssh.SshSessionConfiguration) => T, ): T { const config = new ssh.SshSessionConfiguration(); config.keyExchangeAlgorithms.splice(0); config.keyExchangeAlgorithms.push(ssh.SshAlgorithms.keyExchange.ecdhNistp384Sha384); config.keyExchangeAlgorithms.push(ssh.SshAlgorithms.keyExchange.ecdhNistp256Sha256); config.keyExchangeAlgorithms.push(ssh.SshAlgorithms.keyExchange.dhGroup14Sha256); return factoryCallback(config); } private static nodeSshStreamFactory( relayUri: string, protocols?: string[], headers?: object, clientConfig?: IClientConfig, ): Promise { const client = new WebSocketClient(clientConfig); return new Promise((resolve, reject) => { client.on('connect', (connection: any) => { resolve(new ssh.WebSocketStream(new WebsocketStreamAdapter(connection))); }); // If the server responds but doesn't properly upgrade the connection to web socket, WebSocketClient fires 'httpResponse' event. // TODO: Return ProblemDetails from TunnelRelay service client.on('httpResponse', ({ statusCode, statusMessage }) => { const errorContext = webSocketClientContexts.find( (c) => c.statusCode === statusCode, ) ?? { statusCode, errorType: RelayErrorType.ServerError, error: `relayConnectionError Server responded with a non-101 status: ${statusCode} ${statusMessage}`, }; reject(new RelayConnectionError(`error.${errorContext.error}`, errorContext)); }); // All other failure cases - cannot connect and get the response, or the web socket handshake failed. client.on('connectFailed', ({ message }) => { if (message && message.startsWith('Error: ')) { message = message.substr(7); } const errorContext = webSocketClientContexts.find( (c) => c.regex && c.regex.test(message), ) ?? { // Other errors are most likely connectivity issues. // The original error message may have additional helpful details. errorType: RelayErrorType.ServerError, error: `relayConnectionError ${message}`, }; reject(new RelayConnectionError(`error.${errorContext.error}`, errorContext)); }); client.connect(relayUri, protocols, undefined, headers); }); } } /** * Partially adapts a Node websocket connection object to the browser websocket API, * enough so that it can be used as an SSH stream. */ class WebsocketStreamAdapter { public constructor(private connection: WebSocketConnection) {} public get protocol(): string | undefined { return this.connection.protocol; } public set onmessage(messageHandler: ((e: { data: ArrayBuffer }) => void) | null) { if (messageHandler) { this.connection.on('message', (message: any) => { // This assumes all messages are binary. messageHandler({ data: message.binaryData! }); }); } else { // Removing event handlers is not implemented. } } public set onclose( closeHandler: ((e: { code?: number; reason?: string; wasClean: boolean }) => void) | null, ) { if (closeHandler) { this.connection.on('close', (code: any, reason: any) => { closeHandler({ code, reason, wasClean: !(code || reason) }); }); } else { // Removing event handlers is not implemented. } } public send(data: ArrayBuffer): void { if (Buffer.isBuffer(data)) { this.connection.sendBytes(data); } else { this.connection.sendBytes(Buffer.from(data)); } } public close(code?: number, reason?: string): void { if (code || reason) { this.connection.drop(code, reason); } else { this.connection.close(); } } } /** * Helper function to check the running environment. */ export const isNode = (): boolean => typeof process !== 'undefined' && typeof process.release !== 'undefined' && process.release.name === 'node'; /** * A workspace connection info */ export interface IWorkspaceConnectionInfo { id: string; relayLink?: string; relaySas?: string; hostPublicKeys: string[]; isHostConnected?: boolean; } /** * The ssh session authenticate options */ export interface ISshSessionAuthenticateOptions { sessionToken: string; relaySas: string; } /** * The workspace session info required to join */ export type IWorkspaceSessionInfo = IWorkspaceConnectionInfo & ISshSessionAuthenticateOptions; /** * A shared workspace info */ export interface ISharedWorkspaceInfo extends IWorkspaceConnectionInfo { name: string; joinLink: string; conversationId: string; } /** * Type of relay connection error types. */ export enum RelayErrorType { ConnectionError = 1, Unauthorized = 2, /** * @deprecated This relay error type is not used. */ EndpointNotFound = 3, /** * @deprecated This relay error type is not used. */ ListenerOffline = 4, ServerError = 5, TunnelPortNotFound = 6, TooManyRequests = 7, ServiceUnavailable = 8, } /** * Error used when a connection to the tunnel relay failed. */ export class RelayConnectionError extends Error { public constructor( message: string, public readonly errorContext: { errorType: RelayErrorType; statusCode?: number; }, ) { super(message); } } /** * Web socket client error context. */ interface WebSocketClientErrorContext { readonly regex?: RegExp; readonly statusCode?: number; readonly error: string; readonly errorType: RelayErrorType; } /** * Web socket client error contexts. */ // TODO: Return ProblemDetails from TunnelRelay service. const webSocketClientContexts: WebSocketClientErrorContext[] = [ { regex: /status: 401/, statusCode: 401, error: 'relayClientUnauthorized', errorType: RelayErrorType.Unauthorized, }, { regex: /status: 403/, statusCode: 403, error: 'relayClientForbidden', errorType: RelayErrorType.Unauthorized, }, { regex: /status: 404/, statusCode: 404, error: 'tunnelPortNotFound', errorType: RelayErrorType.TunnelPortNotFound, }, { regex: /status: 429/, statusCode: 429, error: 'tooManyRequests', errorType: RelayErrorType.TooManyRequests, }, { regex: /status: 500/, statusCode: 500, error: 'relayServerError', errorType: RelayErrorType.ServerError, }, { regex: /status: 503/, statusCode: 503, error: 'serviceUnavailable', errorType: RelayErrorType.ServiceUnavailable, }, ]; dev-tunnels-0.0.25/ts/src/connections/tsconfig.json000066400000000000000000000005101450757157500223240ustar00rootroot00000000000000{ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "../../out/lib/connections", "tsBuildInfoFile": "../../out/lib/connections/tsbuildinfo.json", "rootDir": "." }, "include": [ "**/*.ts", "package.json" ], "references": [ { "path": "../contracts" }, { "path": "../management" } ] } dev-tunnels-0.0.25/ts/src/connections/tunnelClient.ts000066400000000000000000000077211450757157500226440ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { Duplex } from 'stream'; import { TunnelConnectionMode, Tunnel } from '@microsoft/dev-tunnels-contracts'; import { CancellationToken } from '@microsoft/dev-tunnels-ssh'; import { ForwardedPortsCollection } from '@microsoft/dev-tunnels-ssh-tcp'; import { TunnelConnection } from './tunnelConnection'; import { TunnelConnectionOptions } from './tunnelConnectionOptions'; /** * Interface for a client capable of making a connection * to a tunnel and forwarding ports over the tunnel. */ export interface TunnelClient extends TunnelConnection { /** * Gets the list of connection modes that this client supports. */ readonly connectionModes: TunnelConnectionMode[]; /** * Gets list of ports forwarded to client, this collection * contains events to notify when ports are forwarded */ readonly forwardedPorts: ForwardedPortsCollection | undefined; /** * Gets a value indicating whether local connections for forwarded ports are accepted. * Local connections are not accepted if the host process is not NodeJS (e.g. browser). * Default: true for NodeJS, false for browser. */ acceptLocalConnectionsForForwardedPorts: boolean; /** * Gets or sets the local network interface address that the tunnel client listens on when * accepting connections for forwarded ports. The default value is the loopback address * (127.0.0.1). Applications may set this to the address indicating any interface (0.0.0.0) * or to the address of a specific interface. The tunnel client supports both IPv4 and IPv6 * when listening on either loopback or any interface. */ localForwardingHostAddress: string; /** * Connects to a tunnel. * * Once connected, tunnel ports are forwarded by the host. * The client either needs to be logged in as the owner identity, or have * an access token with "connect" scope for the tunnel. * * @param tunnel Tunnel to connect to. * @param options Options for the connection. * @param cancellation Optional cancellation token for the connection. */ connect( tunnel: Tunnel, options?: TunnelConnectionOptions, cancellation?: CancellationToken, ): Promise; /** * Opens a stream connected to a remote port for clients which cannot forward local TCP ports, such as browsers. * * This method should only be called after {@link connect}. Calling {@link waitForForwardedPort} * before {@link connectToForwardedPort} may also be necessary in case the port is not yet available. * * @param fowardedPort Remote port to connect to. * @param cancellation Optional cancellation token for the request. * @returns A stream that is relayed to the remote port. */ connectToForwardedPort( fowardedPort: number, cancellation?: CancellationToken, ): Promise; /** * Waits for the specified port to be forwarded by the remote host. * * Call before {@link connectToForwardedPort} to ensure that a forwarded port is available before attempting to connect. * * @param forwardedPort Remote port to wait for. * @param cancellation Optional cancellation for the request. */ waitForForwardedPort(forwardedPort: number, cancellation?: CancellationToken): Promise; /** * Sends a request to the host to refresh ports that were updated using the management API, * and waits for the refresh to complete. * * After using the management API to add or remove ports, call this method to have a * connected client notify the host to update its cached list of ports. Any added or * removed ports will then propagate back to the set of ports forwarded by the current * client. After the returned task has completed, any newly added ports are usable from * the current client. */ refreshPorts(): Promise; } dev-tunnels-0.0.25/ts/src/connections/tunnelClientBase.ts000066400000000000000000000467621450757157500234470ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { Duplex } from 'stream'; import { Tunnel, TunnelAccessScopes, TunnelConnectionMode, TunnelEndpoint, } from '@microsoft/dev-tunnels-contracts'; import { CancellationToken, SecureStream, SessionRequestMessage, SshAlgorithms, SshAuthenticatingEventArgs, SshAuthenticationType, SshClientCredentials, SshClientSession, SshDisconnectReason, SshProtocolExtensionNames, SshRequestEventArgs, SshSessionClosedEventArgs, Stream, Trace, TraceLevel, } from '@microsoft/dev-tunnels-ssh'; import { ForwardedPortConnectingEventArgs, ForwardedPortEventArgs, ForwardedPortsCollection, PortForwardingService } from '@microsoft/dev-tunnels-ssh-tcp'; import { RetryTcpListenerFactory } from './retryTcpListenerFactory'; import { isNode, SshHelpers } from './sshHelpers'; import { TunnelClient } from './tunnelClient'; import { getError, List } from './utils'; import { Emitter } from 'vscode-jsonrpc'; import { TunnelConnectionSession } from './tunnelConnectionSession'; import { TunnelManagementClient } from '@microsoft/dev-tunnels-management'; import { tunnelSshSessionClass } from './tunnelSshSessionClass'; import { PortRelayConnectResponseMessage } from './messages/portRelayConnectResponseMessage'; import { ConnectionStatus } from './connectionStatus'; import { TunnelConnectionOptions } from './tunnelConnectionOptions'; export const webSocketSubProtocol = 'tunnel-relay-client'; export const webSocketSubProtocolv2 = 'tunnel-relay-client-v2-dev'; /** * Base class for clients that connect to a single host */ export class TunnelClientBase extends tunnelSshSessionClass(TunnelConnectionSession) implements TunnelClient { private readonly sshSessionClosedEmitter = new Emitter(); private acceptLocalConnectionsForForwardedPortsValue: boolean = isNode(); private localForwardingHostAddressValue: string = '127.0.0.1'; private hostId?: string; private readonly disconnectedStreams = new Map(); public connectionModes: TunnelConnectionMode[] = []; /** * Tunnel endpoints this client connects to. * Depending on implementation, the client may connect to one or more endpoints. */ public endpoints?: TunnelEndpoint[]; /** * One or more SSH public keys published by the host with the tunnel endpoint. */ protected hostPublicKeys?: string[]; protected get isSshSessionActive(): boolean { return !!this.sshSession?.isConnected; } protected readonly sshSessionClosed = this.sshSessionClosedEmitter.event; /** * Get a value indicating if remote port is forwarded and has any channels open on the client, * whether used by local tcp listener if {AcceptLocalConnectionsForForwardedPorts} is true, or * streamed via . */ protected hasForwardedChannels(port: number): boolean { if (!this.isSshSessionActive) { return false; } const pfs = this.sshSession?.activateService(PortForwardingService); const remoteForwardedPorts = pfs?.remoteForwardedPorts; const forwardedPort = remoteForwardedPorts?.find((p) => p.remotePort === port); return !!forwardedPort && remoteForwardedPorts!.getChannels(forwardedPort).length > 0; } /** * A value indicating whether local connections for forwarded ports are accepted. * Local connections are not accepted if the host is not NodeJS (e.g. browser). */ public get acceptLocalConnectionsForForwardedPorts(): boolean { return this.acceptLocalConnectionsForForwardedPortsValue; } public set acceptLocalConnectionsForForwardedPorts(value: boolean) { if (value === this.acceptLocalConnectionsForForwardedPortsValue) { return; } if (value && !isNode()) { throw new Error( 'Cannot accept local connections for forwarded ports on this platform.', ); } this.acceptLocalConnectionsForForwardedPortsValue = value; this.configurePortForwardingService(); } /** * Gets the local network interface address that the tunnel client listens on when * accepting connections for forwarded ports. */ public get localForwardingHostAddress(): string { return this.localForwardingHostAddressValue; } public set localForwardingHostAddress(value: string) { if (value !== this.localForwardingHostAddressValue) { this.localForwardingHostAddressValue = value; this.configurePortForwardingService(); } } public get forwardedPorts(): ForwardedPortsCollection | undefined { const pfs = this.sshSession?.activateService(PortForwardingService); return pfs?.remoteForwardedPorts; } public constructor(trace?: Trace, managementClient?: TunnelManagementClient) { super(TunnelAccessScopes.Connect, trace, managementClient); } public async connect( tunnel: Tunnel, options?: TunnelConnectionOptions, cancellation?: CancellationToken, ): Promise { this.hostId = options?.hostId; await this.connectTunnelSession(tunnel, options, cancellation); } protected tunnelChanged() { super.tunnelChanged(); this.endpoints = undefined; if (this.tunnel) { if (!this.tunnel.endpoints) { throw new Error('Tunnel endpoints cannot be null'); } if (this.tunnel.endpoints.length === 0) { throw new Error('No hosts are currently accepting connections for the tunnel.'); } const endpointGroups = List.groupBy( this.tunnel.endpoints, (endpoint: TunnelEndpoint) => endpoint.hostId, ); if (this.hostId) { this.endpoints = endpointGroups.get(this.hostId)!; if (!this.endpoints) { throw new Error( 'The specified host is not currently accepting connections to the tunnel.', ); } } else if (endpointGroups.size > 1) { throw new Error( 'There are multiple hosts for the tunnel. Specify a host ID to connect to.', ); } else { this.endpoints = endpointGroups.entries().next().value[1]; } } } private onRequest(e: SshRequestEventArgs) { if ( e.request.requestType === PortForwardingService.portForwardRequestType || e.request.requestType === PortForwardingService.cancelPortForwardRequestType ) { e.isAuthorized = true; } } public startSshSession(stream: Stream, cancellation?: CancellationToken): Promise { return this.connectSession(async () => { this.sshSession = SshHelpers.createSshClientSession((config) => { // Enable port-forwarding via the SSH protocol. config.addService(PortForwardingService); if (this.connectionProtocol === webSocketSubProtocol) { // Enable client SSH session reconnect for V1 protocol only. // (V2 SSH reconnect is handled by the SecureStream class.) config.protocolExtensions.push(SshProtocolExtensionNames.sessionReconnect); } else { // The V2 protocol configures optional encryption, including "none" as an enabled // and preferred key-exchange algorithm, because encryption of the outer SSH // session is optional since it is already over a TLS websocket. config.keyExchangeAlgorithms.splice(0, 0, SshAlgorithms.keyExchange.none); } }); this.sshSession.trace = this.trace; this.sshSession.onClosed((e) => this.onSshSessionClosed(e)); this.sshSession.onAuthenticating((e) => this.onSshServerAuthenticating(e)); this.sshSession.onDisconnected((e) => this.onSshSessionDisconnected()); try { this.configurePortForwardingService(); this.sshSession.onRequest((e) => this.onRequest(e)); await this.sshSession.connect(stream, cancellation); // SSH authentication is required in V1 protocol, optional in V2 depending on // whether the session enabled key exchange (as indicated by having a session ID // or not).In either case a password is not required. Strong authentication was // already handled by the relay service via the tunnel access token used for the // websocket connection. if (this.sshSession.sessionId) { const clientCredentials: SshClientCredentials = { username: 'tunnel' }; if (!(await this.sshSession.authenticate(clientCredentials, cancellation))) { throw new Error(this.sshSession.principal ? 'SSH client authentication failed.' : 'SSH server authentication failed.'); } } } catch (e) { const error = getError(e, 'Error starting tunnel client SSH session: '); await this.closeSession(error); throw error; } }); } private configurePortForwardingService() { if (!this.sshSession) { return; } const pfs = this.sshSession.activateService(PortForwardingService); // Do not start forwarding local connections for browser client connections or if this is not allowed. if (this.acceptLocalConnectionsForForwardedPortsValue && isNode()) { pfs.tcpListenerFactory = new RetryTcpListenerFactory( this.localForwardingHostAddressValue, ); } else { pfs.acceptLocalConnectionsForForwardedPorts = false; } if (this.connectionProtocol === webSocketSubProtocolv2) { pfs.messageFactory = this; pfs.onForwardedPortConnecting((e) => this.onForwardedPortConnecting(e)); pfs.remoteForwardedPorts.onPortAdded((e) => this.onForwardedPortAdded(pfs, e)); pfs.remoteForwardedPorts.onPortUpdated((e) => this.onForwardedPortAdded(pfs, e)); } } private onForwardedPortAdded(pfs: PortForwardingService, e: ForwardedPortEventArgs) { const port = e.port.remotePort; if (typeof port !== 'number') { return; } // If there are disconnected streams for the port, re-connect them now. const disconnectedStreamsCount = this.disconnectedStreams.get(port)?.length ?? 0; for (let i = 0; i < disconnectedStreamsCount; i++) { pfs.connectToForwardedPort(port) .then(() => { this.trace(TraceLevel.Verbose, 0, `Reconnected stream to fowarded port ${port}`); }).catch((error) => { this.trace( TraceLevel.Warning, 0, `Failed to reconnect to forwarded port ${port}: ${error}`); // The host is no longer accepting connections on the forwarded port? // Clear the list of disconnected streams for the port, because // it seems it is no longer possible to reconnect them. const streams = this.disconnectedStreams.get(port); if (streams) { while (streams.length > 0) { streams.pop()!.dispose(); } } }); } } /** * Invoked when a forwarded port is connecting. (Only for V2 protocol.) */ protected onForwardedPortConnecting(e: ForwardedPortConnectingEventArgs) { // With V2 protocol, the relay server always sends an extended response message // with a property indicating whether E2E encryption is enabled for the connection. const channel = e.stream.channel; const relayResponseMessage = channel.openConfirmationMessage .convertTo(new PortRelayConnectResponseMessage()); if (relayResponseMessage.isE2EEncryptionEnabled) { // The host trusts the relay to authenticate the client, so it doesn't require // any additional password/token for client authentication. const clientCredentials: SshClientCredentials = { username: "tunnel" }; e.transformPromise = new Promise((resolve, reject) => { // If there's a disconnected SecureStream for the port, try to reconnect it. // If there are multiple, pick one and the host will match by SSH session ID. let secureStream = this.disconnectedStreams.get(e.port)?.shift(); if (secureStream) { this.trace( TraceLevel.Verbose, 0, `Reconnecting encrypted stream for port ${e.port}...`); secureStream.reconnect(e.stream) .then(() => { this.trace( TraceLevel.Verbose, 0, `Reconnecting encrypted stream for port ${e.port} succeeded.`); resolve(secureStream!); }).catch(reject); } else { secureStream = new SecureStream( e.stream, clientCredentials); secureStream.trace = this.trace; secureStream.onAuthenticating((authEvent) => authEvent.authenticationPromise = this.onHostAuthenticating(authEvent).catch()); secureStream.onDisconnected( () => this.onSecureStreamDisconnected(e.port, secureStream!)); // Do not pass the cancellation token from the connecting event, // because the connection will outlive the event. secureStream.connect().then(() => resolve(secureStream!)).catch(reject); } }); } super.onForwardedPortConnecting(e); } private onSecureStreamDisconnected(port: number, secureStream: SecureStream): void { this.trace(TraceLevel.Verbose, 0, `Encrypted stream for port ${port} disconnected.`); const streams = this.disconnectedStreams.get(port); if (streams) { streams.push(secureStream); } else { this.disconnectedStreams.set(port, [secureStream]); } } private async onHostAuthenticating(e: SshAuthenticatingEventArgs): Promise { if (e.authenticationType !== SshAuthenticationType.serverPublicKey || !e.publicKey) { this.traceWarning('Invalid host authenticating event.'); return null; } // The public key property on this event comes from SSH key-exchange; at this point the // SSH server has cryptographically proven that it holds the corresponding private key. // Convert host key bytes to base64 to match the format in which the keys are published. const hostKey = (await e.publicKey.getPublicKeyBytes(e.publicKey.keyAlgorithmName)) ?.toString('base64') ?? ''; // Host public keys are obtained from the tunnel endpoint record published by the host. if (!this.hostPublicKeys) { this.traceWarning('Host identity could not be verified because ' + 'no public keys were provided.'); this.traceVerbose(`Host key: ${hostKey}`); return {}; } else if (this.hostPublicKeys.includes(hostKey)) { this.traceVerbose(`Verified host identity with public key ${hostKey}`); return {}; } else { // The tunnel host may have reconnected with a different host public key. // Try fetching the tunnel again to refresh the key. if (this.canRefreshTunnel && !this.disposeToken.isCancellationRequested) { const previousStatus = this.connectionStatus; this.connectionStatus = ConnectionStatus.RefreshingTunnelHostPublicKey; try { await this.refreshTunnel(this.disposeToken); if (this.hostPublicKeys.includes(hostKey)) { this.traceVerbose('Verified host identity with public key ' + hostKey); return {}; } } finally { this.connectionStatus = previousStatus; } } this.traceError('Host public key verification failed.'); this.traceVerbose(`Host key: ${hostKey}`); this.traceVerbose(`Expected key(s): ${this.hostPublicKeys.join(', ')}`); return null; } } private onSshServerAuthenticating(e: SshAuthenticatingEventArgs): void { if (this.connectionProtocol === webSocketSubProtocol) { // For V1 protocol the SSH server is the host; it should be authenticated with public key. e.authenticationPromise = this.onHostAuthenticating(e); } else { // For V2 protocol the SSH server is the relay. // Relay server authentication is done via the websocket TLS host certificate. // If SSH encryption/authentication is used anyway, just accept any SSH host key. e.authenticationPromise = Promise.resolve({}); } } public async connectToForwardedPort( fowardedPort: number, cancellation?: CancellationToken, ): Promise { const pfs = this.getSshSessionPfs(); if (!pfs) { throw new Error( 'Failed to connect to remote port. Ensure that the client has connected by calling connectClient.', ); } return pfs.connectToForwardedPort(fowardedPort, cancellation); } public async waitForForwardedPort( forwardedPort: number, cancellation?: CancellationToken, ): Promise { const pfs = this.getSshSessionPfs(); if (!pfs) { throw new Error( 'Port forwarding has not been started. Ensure that the client has connected by calling connectClient.', ); } this.trace(TraceLevel.Verbose, 0, 'Waiting for forwarded port ' + forwardedPort); await pfs.waitForForwardedPort(forwardedPort, cancellation); this.trace(TraceLevel.Verbose, 0, 'Forwarded port ' + forwardedPort + ' is ready.'); } private getSshSessionPfs() { return this.sshSession?.getService(PortForwardingService) ?? undefined; } public async refreshPorts(): Promise { if (!this.sshSession || this.sshSession.isClosed) { throw new Error('Not connected.'); } const request = new SessionRequestMessage(); request.requestType = 'RefreshPorts'; request.wantReply = true; await this.sshSession.request(request); } private onSshSessionClosed(e: SshSessionClosedEventArgs) { this.sshSessionClosedEmitter.fire(this); if (e.reason === SshDisconnectReason.connectionLost) { this.startReconnectingIfNotDisposed(); } } private onSshSessionDisconnected() { this.startReconnectingIfNotDisposed(); } } dev-tunnels-0.0.25/ts/src/connections/tunnelConnection.ts000066400000000000000000000035051450757157500235210ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { Event } from 'vscode-jsonrpc'; import { ConnectionStatus } from './connectionStatus'; import { ConnectionStatusChangedEventArgs } from './connectionStatusChangedEventArgs'; import { RefreshingTunnelAccessTokenEventArgs } from './refreshingTunnelAccessTokenEventArgs'; import { RetryingTunnelConnectionEventArgs } from './retryingTunnelConnectionEventArgs'; import { ForwardedPortConnectingEventArgs } from '@microsoft/dev-tunnels-ssh-tcp'; /** * Tunnel connection. */ export interface TunnelConnection { /** * Gets the connection status. */ readonly connectionStatus: ConnectionStatus; /** * Gets the error that caused disconnection. * Undefined if not yet connected or disconnection was caused by disposing of this object. */ readonly disconnectError?: Error; /** * Event for refreshing the tunnel access token. * The tunnel client will fire this event when it is not able to use the access token it got from the tunnel. */ readonly refreshingTunnelAccessToken: Event; /** * Connection status changed event. */ readonly connectionStatusChanged: Event; /** * Event raised when a tunnel connection attempt failed and is about to be retried. * An event handler can cancel the retry by setting {@link RetryingTunnelConnectionEventArgs.retry} to false. */ readonly retryingTunnelConnection: Event; /** * An event which fires when a connection is made to the forwarded port. */ readonly forwardedPortConnecting: Event; /** * Disposes this tunnel session. */ dispose(): Promise; } dev-tunnels-0.0.25/ts/src/connections/tunnelConnectionBase.ts000066400000000000000000000143241450757157500243150ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { CancellationError, ObjectDisposedError } from '@microsoft/dev-tunnels-ssh'; import { CancellationToken, CancellationTokenSource, Emitter } from 'vscode-jsonrpc'; import { ConnectionStatus } from './connectionStatus'; import { ConnectionStatusChangedEventArgs } from './connectionStatusChangedEventArgs'; import { TunnelConnection } from './tunnelConnection'; import { RefreshingTunnelAccessTokenEventArgs } from './refreshingTunnelAccessTokenEventArgs'; import { RetryingTunnelConnectionEventArgs } from './retryingTunnelConnectionEventArgs'; import { TunnelAccessTokenProperties } from '@microsoft/dev-tunnels-management'; import { ForwardedPortConnectingEventArgs } from '@microsoft/dev-tunnels-ssh-tcp'; /** * Tunnel connection base class. */ export class TunnelConnectionBase implements TunnelConnection { private readonly disposeCts = new CancellationTokenSource(); private status = ConnectionStatus.None; private error?: Error; protected isRefreshingTunnelAccessTokenEventHandled = false; private readonly refreshingTunnelAccessTokenEmitter = new Emitter< RefreshingTunnelAccessTokenEventArgs >({ onFirstListenerAdd: () => (this.isRefreshingTunnelAccessTokenEventHandled = true), onLastListenerRemove: () => (this.isRefreshingTunnelAccessTokenEventHandled = false), }); private readonly connectionStatusChangedEmitter = new Emitter(); private readonly retryingTunnelConnectionEmitter = new Emitter(); private readonly forwardedPortConnectingEmitter = new Emitter(); protected constructor( /** * Gets tunnel access scope for this tunnel session. */ public readonly tunnelAccessScope: string, ) {} /** * Gets a value indicathing that this tunnel connection session is disposed. */ public get isDisposed() { return this.disposeCts.token.isCancellationRequested; } /** * Gets dispose cancellation token. */ protected get disposeToken(): CancellationToken { return this.disposeCts.token; } /** * Gets the connection status. */ public get connectionStatus(): ConnectionStatus { return this.status; } /** * Sets the connection status. * Throws CancellationError if the session is disposed and the status being set is not ConnectionStatus.Disconnected. */ protected set connectionStatus(value: ConnectionStatus) { if (this.isDisposed && value !== ConnectionStatus.Disconnected) { this.throwIfDisposed(); } if (value !== this.status) { const previousStatus = this.connectionStatus; this.status = value; if (value === ConnectionStatus.Connected) { this.error = undefined; } this.onConnectionStatusChanged(previousStatus, value); } } /** * Gets the error that caused disconnection. * Undefined if not yet connected or disconnection was caused by disposing of this object. */ public get disconnectError(): Error | undefined { return this.error; } /** * Sets the error that caused disconnection. */ protected set disconnectError(e: Error | undefined) { this.error = e; } /** * Event for refreshing the tunnel access token. * The tunnel client will fire this event when it is not able to use the access token it got from the tunnel. */ public readonly refreshingTunnelAccessToken = this.refreshingTunnelAccessTokenEmitter.event; /** * Connection status changed event. */ public readonly connectionStatusChanged = this.connectionStatusChangedEmitter.event; /** * Event raised when a tunnel connection attempt failed and is about to be retried. * An event handler can cancel the retry by setting {@link RetryingTunnelConnectionEventArgs.retry} to false. */ public readonly retryingTunnelConnection = this.retryingTunnelConnectionEmitter.event; /** * An event which fires when a connection is made to the forwarded port. */ public readonly forwardedPortConnecting = this.forwardedPortConnectingEmitter.event; protected onForwardedPortConnecting(e: ForwardedPortConnectingEventArgs) { this.forwardedPortConnectingEmitter.fire(e); } /** * Closes and disposes the tunnel session. */ public dispose(): Promise { this.disposeCts.cancel(); this.connectionStatus = ConnectionStatus.Disconnected; return Promise.resolve(); } /** * Notifies about a connection retry, giving the relay client a chance to delay or cancel it. */ public onRetrying(event: RetryingTunnelConnectionEventArgs): void { this.retryingTunnelConnectionEmitter.fire(event); } /** * Gets the fresh tunnel access token or undefined if it cannot. */ protected async getFreshTunnelAccessToken( cancellation: CancellationToken, ): Promise { const event = new RefreshingTunnelAccessTokenEventArgs( this.tunnelAccessScope, cancellation, ); this.refreshingTunnelAccessTokenEmitter.fire(event); const result = event.tunnelAccessToken ? await event.tunnelAccessToken : undefined; if (result) { TunnelAccessTokenProperties.validateTokenExpiration(result); } return result; } /** * Event fired when the connection status has changed. */ protected onConnectionStatusChanged( previousStatus: ConnectionStatus, status: ConnectionStatus, ) { const event = new ConnectionStatusChangedEventArgs( previousStatus, status, this.disconnectError, ); this.connectionStatusChangedEmitter.fire(event); } /** * Throws CancellationError if the tunnel connection is disposed. */ protected throwIfDisposed() { if (this.isDisposed) { throw new ObjectDisposedError('The tunnel connection is disposed.'); } } } dev-tunnels-0.0.25/ts/src/connections/tunnelConnectionOptions.ts000066400000000000000000000045561450757157500251040ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import * as http from 'http'; /** * Options for a tunnel host or client connection. */ export interface TunnelConnectionOptions { /** * Gets or sets a value indicating whether the connection will be automatically retried after * a connection failure. * * The default value is true. When enabled, retries continue until the connection is * successful, the cancellation token is cancelled, or an unrecoverable error is encountered. * * Recoverable errors include network connectivity issues, authentication issues (e.g. expired * access token which may be refreshed before retrying), and service temporarily unavailable * (HTTP 503). For rate-limiting errors (HTTP 429) only a limited number of retries are * attempted before giving up. * * Retries are performed with exponential backoff, starting with a 100ms delay and doubling * up to a maximum 12s delay, with further retries using the same max delay. * * Note after the initial connection succeeds, the host or client may still become disconnected * at any time after that. In that case the `enableReconnect` option controls whether an * automatic reconnect will be attempted. Reconnection has the same retry behavior. * * Listen to the `retryingTunnelConnection` event to be notified when the connection is * retrying. */ enableRetry?: boolean; /** * Gets or sets a value indicating whether the connection will attempt to automatically * reconnect (with no data loss) after a disconnection. * * The default value is true. * * If reconnection fails, or is not enabled, the application may still attempt to connect * the client again, however in that case no state is preserved. * * Listen to the `connectionStatusChanged` event to be notified when reconnection or * disconnection occurs. */ enableReconnect?: boolean; /** * Gets or sets the HTTP agent to use for the connection. */ httpAgent?: http.Agent; /** * Gets or sets the ID of the tunnel host to connect to, if there are multiple * hosts accepting connections on the tunnel, or null to connect to a single host * (most common). This option applies only to client connections. */ hostId?: string; } dev-tunnels-0.0.25/ts/src/connections/tunnelConnectionSession.ts000066400000000000000000000331501450757157500250640ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { Tunnel, TunnelAccessScopes } from '@microsoft/dev-tunnels-contracts'; import { TunnelAccessTokenProperties, TunnelManagementClient, TunnelRequestOptions, } from '@microsoft/dev-tunnels-management'; import { CancellationError, ObjectDisposedError, Stream, Trace, TraceLevel } from '@microsoft/dev-tunnels-ssh'; import { CancellationToken, CancellationTokenSource, Disposable } from 'vscode-jsonrpc'; import { ConnectionStatus } from './connectionStatus'; import { RelayTunnelConnector } from './relayTunnelConnector'; import { TunnelConnector } from './tunnelConnector'; import { TunnelSession } from './tunnelSession'; import { withCancellation } from './utils'; import { TunnelConnectionBase } from './tunnelConnectionBase'; import { PortForwardChannelOpenMessage, PortForwardRequestMessage, PortForwardSuccessMessage, } from '@microsoft/dev-tunnels-ssh-tcp'; import { PortRelayRequestMessage } from './messages/portRelayRequestMessage'; import { PortRelayConnectRequestMessage } from './messages/portRelayConnectRequestMessage'; import * as http from 'http'; import { TunnelConnectionOptions } from './tunnelConnectionOptions'; /** * Tunnel connection session. */ export class TunnelConnectionSession extends TunnelConnectionBase implements TunnelSession { private connectionOptions?: TunnelConnectionOptions; private connectedTunnel: Tunnel | null = null; private connector?: TunnelConnector; private reconnectPromise?: Promise; private connectionProtocolValue?: string; public httpAgent?: http.Agent; /** * Name of the protocol used to connect to the tunnel. */ public get connectionProtocol(): string | undefined { return this.connectionProtocolValue; } protected set connectionProtocol(value: string | undefined) { this.connectionProtocolValue = value; } /** * Tunnel access token. */ protected accessToken?: string; public constructor( tunnelAccessScope: string, trace?: Trace, /** * Gets the management client used for the connection. */ protected readonly managementClient?: TunnelManagementClient, ) { super(tunnelAccessScope); this.trace = trace ?? (() => {}); this.httpAgent = managementClient?.httpsAgent; } /** * Gets the trace source. */ public trace: Trace; /** * Get the tunnel of this tunnel connection. */ public get tunnel(): Tunnel | null { return this.connectedTunnel; } private set tunnel(value: Tunnel | null) { if (value !== this.connectedTunnel) { this.connectedTunnel = value; this.tunnelChanged(); } } /** * Tunnel has been assigned to or changed. */ protected tunnelChanged() { if (this.tunnel) { this.accessToken = TunnelAccessTokenProperties.getTunnelAccessToken(this.tunnel, this.tunnelAccessScope); } else { this.accessToken = undefined; } } /** * Determines whether E2E encryption is requested when opening connections through the tunnel * (V2 protocol only). * * The default value is true, but applications may set this to false (for slightly faster * connections). * * Note when this is true, E2E encryption is not strictly required. The tunnel relay and * tunnel host can decide whether or not to enable E2E encryption for each connection, * depending on policies and capabilities. Applications can verify the status of E2EE by * handling the `forwardedPortConnecting` event and checking the related property on the * channel request or response message. */ public enableE2EEncryption: boolean = true; /** * Gets a value indicating that this connection has already created its connector * and so can be reconnected if needed. */ protected get isReconnectable(): boolean { return !!this.connector; } /** * Creates a stream to the tunnel. */ public createSessionStream( options?: TunnelConnectionOptions, cancellation?: CancellationToken, ): Promise<{ stream: Stream, protocol: string }> { throw new Error('Not implemented'); } /** * Configures the tunnel session with the given stream. */ public configureSession( stream: Stream, protocol: string, isReconnect: boolean, cancellation: CancellationToken, ): Promise { throw new Error('Not implemented'); } /** * Closes the tunnel session due to an error. */ public closeSession(error?: Error): Promise { this.disconnectError = error; return Promise.resolve(); } /** * Refreshes the tunnel access token. This may be useful when the Relay service responds with 401 Unauthorized. * Does nothing if the object is disposed, or there is no way to refresh the token. */ public async refreshTunnelAccessToken(cancellation: CancellationToken): Promise { if (this.isDisposed) { return false; } if (!this.isRefreshingTunnelAccessTokenEventHandled && !this.canRefreshTunnel) { return false; } const previousStatus = this.connectionStatus; this.connectionStatus = ConnectionStatus.RefreshingTunnelAccessToken; try { this.traceVerbose( `Refreshing tunnel access token. Current token: ${TunnelAccessTokenProperties.getTokenTrace( this.accessToken, )}`, ); if (this.isRefreshingTunnelAccessTokenEventHandled) { this.accessToken = await this.getFreshTunnelAccessToken(cancellation) ?? undefined; } else { await this.refreshTunnel(cancellation); } if (this.accessToken) { TunnelAccessTokenProperties.validateTokenExpiration(this.accessToken); } this.traceVerbose( `Refreshed tunnel access token. New token: ${TunnelAccessTokenProperties.getTokenTrace( this.accessToken, )}`, ); return true; } finally { this.connectionStatus = previousStatus; } return false; } /** * Get a value indicating whether this session can attempt refreshing tunnel. * Note: tunnel refresh may still fail if the tunnel doesn't exist in the service, * tunnel access has changed, or tunnel access token has expired. */ protected get canRefreshTunnel() { return this.tunnel && this.managementClient; } /** * Fetch the tunnel from the service if {@link managementClient} and {@link tunnel} are set. */ protected async refreshTunnel(cancellation?: CancellationToken) { if (this.canRefreshTunnel) { this.traceInfo('Refreshing tunnel.'); const options: TunnelRequestOptions = { tokenScopes: [this.tunnelAccessScope], }; this.tunnel = await withCancellation( this.managementClient!.getTunnel(this.tunnel!, options), cancellation, ); if (this.tunnel) { this.traceInfo('Refreshed tunnel.'); } else { this.traceInfo('Tunnel not found.'); } } } /** * Creates a tunnel connector */ protected createTunnelConnector(): TunnelConnector { return new RelayTunnelConnector(this); } /** * Trace info message. */ protected traceInfo(msg: string) { this.trace(TraceLevel.Info, 0, msg); } /** * Trace verbose message. */ protected traceVerbose(msg: string) { this.trace(TraceLevel.Verbose, 0, msg); } /** * Trace warning message. */ protected traceWarning(msg: string, err?: Error) { this.trace(TraceLevel.Warning, 0, msg, err); } /** * Trace error message. */ protected traceError(msg: string, err?: Error) { this.trace(TraceLevel.Error, 0, msg, err); } /** * Start reconnecting if the tunnel connection is not yet disposed. */ protected startReconnectingIfNotDisposed() { if (!this.isDisposed && (this.connectionOptions?.enableReconnect ?? true) && !this.reconnectPromise ) { this.reconnectPromise = (async () => { try { await this.connectTunnelSession(); } catch { // Tracing of the error has already been done by connectTunnelSession. // As reconnection is an async process, there is nobody watching it throw. // The error, if it was not cancellation, is stored in disconnectError property. // There might have been connectionStatusChanged event fired as well. } this.reconnectPromise = undefined; })(); } else { this.connectionStatus = ConnectionStatus.Disconnected; } } /** * Connect to the tunnel session by running the provided {@link action}. */ public async connectSession(action: () => Promise): Promise { this.connectionStatus = ConnectionStatus.Connecting; try { await action(); this.connectionStatus = ConnectionStatus.Connected; } catch (e) { if (!(e instanceof CancellationError)) { const name = this.tunnelAccessScope === TunnelAccessScopes.Connect ? 'client' : 'host'; if (e instanceof Error) { this.traceError(`Error connecting ${name} tunnel session: ${e.message}`, e); this.disconnectError = e; } else { const message = `Error connecting ${name} tunnel session: ${e}`; this.traceError(message); this.disconnectError = new Error(message); } } this.connectionStatus = ConnectionStatus.Disconnected; throw e; } } /** * Connect to the tunnel session with the tunnel connector. * @param tunnel Tunnel to use for the connection. * Undefined if the connection information is already known and the tunnel is not needed. * Tunnel object to get the connection information from that tunnel. */ public async connectTunnelSession( tunnel?: Tunnel, options?: TunnelConnectionOptions, cancellation?: CancellationToken): Promise { if (tunnel) { this.tunnel = tunnel; } if (options) { this.connectionOptions = options; this.httpAgent ??= options?.httpAgent; } await this.connectSession(async () => { const isReconnect = this.isReconnectable && !tunnel; await this.onConnectingToTunnel(); if (!this.connector) { this.connector = this.createTunnelConnector(); } const disposables: Disposable[] = []; if (cancellation) { // Link the provided cancellation token with the dispose token. const linkedCancellationSource = new CancellationTokenSource(); disposables.push( linkedCancellationSource, cancellation.onCancellationRequested(() => linkedCancellationSource!.cancel()), this.disposeToken.onCancellationRequested(() => linkedCancellationSource!.cancel()), ); cancellation = linkedCancellationSource.token; } else { cancellation = this.disposeToken; } try { await this.connector.connectSession(isReconnect, options, cancellation); } catch (e) { if (e instanceof CancellationError) { this.throwIfDisposed(); } throw e; } finally { for (const disposable of disposables) disposable.dispose(); } }); } /** * Validate the {@link tunnel} and get data needed to connect to it, if the tunnel is provided; * otherwise, ensure that there is already sufficient data to connect to a tunnel. */ public onConnectingToTunnel(): Promise { return Promise.resolve(); } /** * Validates tunnel access token if it's present. Returns the token. */ public validateAccessToken(): string | undefined { if (this.accessToken) { TunnelAccessTokenProperties.validateTokenExpiration(this.accessToken); return this.accessToken; } } /** @internal */ public createRequestMessageAsync(port: number): Promise { const message = new PortRelayRequestMessage(); message.accessToken = this.accessToken; return Promise.resolve(message); } /** @internal */ public createSuccessMessageAsync(port: number): Promise { const message = new PortForwardSuccessMessage(); return Promise.resolve(message); } /** @internal */ public createChannelOpenMessageAsync(port: number): Promise { const message = new PortRelayConnectRequestMessage(); message.accessToken = this.accessToken; message.isE2EEncryptionRequested = this.enableE2EEncryption; return Promise.resolve(message); } } dev-tunnels-0.0.25/ts/src/connections/tunnelConnector.ts000066400000000000000000000012071450757157500233510ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { CancellationToken } from '@microsoft/dev-tunnels-ssh'; import { TunnelConnectionOptions } from './tunnelConnectionOptions'; /** * Tunnel connector. */ export interface TunnelConnector { /** * Connect or reconnect tunnel SSH session. * @param isReconnect A value indicating if this is a reconnect (true) or regular connect (false). * @param cancellation Cancellation token. */ connectSession( isReconnect: boolean, options?: TunnelConnectionOptions, cancellation?: CancellationToken, ): Promise; } dev-tunnels-0.0.25/ts/src/connections/tunnelHost.ts000066400000000000000000000033041450757157500223340ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { Tunnel, TunnelPort } from '@microsoft/dev-tunnels-contracts'; import { TunnelConnection } from './tunnelConnection'; import { TunnelConnectionOptions } from './tunnelConnectionOptions'; import { CancellationToken } from '@microsoft/dev-tunnels-ssh'; /** * Interface for a host capable of sharing local ports via * a tunnel and accepting tunneled connections to those ports. */ export interface TunnelHost extends TunnelConnection { /** * Connects to a tunnel as a host and starts accepting incoming connections * to local ports as defined on the tunnel. * @deprecated Use `connect()` instead. */ start(tunnel: Tunnel): Promise; /** * Connects to a tunnel as a host and starts accepting incoming connections * to local ports as defined on the tunnel. * * The host either needs to be logged in as the owner identity, or have * an access token with "host" scope for the tunnel. * * @param tunnel Tunnel to connect to. * @param options Options for the connection. * @param cancellation Optional cancellation token for the connection. */ connect( tunnel: Tunnel, options?: TunnelConnectionOptions, cancellation?: CancellationToken, ): Promise; /** * Refreshes ports that were updated using the management API. * * After using the management API to add or remove ports, call this method to have the * host update its cached list of ports. Any added or removed ports will then propagate to * the set of ports forwarded by all connected clients. */ refreshPorts(): Promise; } dev-tunnels-0.0.25/ts/src/connections/tunnelHostBase.ts000066400000000000000000000127021450757157500231310ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { TunnelPort, Tunnel, TunnelAccessScopes } from '@microsoft/dev-tunnels-contracts'; import { TunnelManagementClient } from '@microsoft/dev-tunnels-management'; import { CancellationToken, KeyPair, SshAlgorithms, SshClientSession, SshServerSession, Trace, } from '@microsoft/dev-tunnels-ssh'; import { PortForwardingService, RemotePortForwarder } from '@microsoft/dev-tunnels-ssh-tcp'; import { SessionPortKey } from './sessionPortKey'; import { TunnelConnectionSession } from './tunnelConnectionSession'; import { TunnelHost } from './tunnelHost'; import { tunnelSshSessionClass } from './tunnelSshSessionClass'; import { isNode } from './sshHelpers'; import { TunnelConnectionOptions } from './tunnelConnectionOptions'; /** * Base class for Hosts that host one tunnel and use SSH to connect to the tunnel host service. */ export class TunnelHostBase extends tunnelSshSessionClass(TunnelConnectionSession) implements TunnelHost { /** * Sessions created between this host and clients * @internal */ public readonly sshSessions: SshServerSession[] = []; /** * Port Forwarders between host and clients */ public readonly remoteForwarders = new Map(); /** * Private key used for connections. */ public hostPrivateKey?: KeyPair; /** * Public keys used for connections. */ public hostPublicKeys?: string[]; /** * Promise task to get private key used for connections. */ public hostPrivateKeyPromise?: Promise; private loopbackIp = '127.0.0.1'; private forwardConnectionsToLocalPortsValue: boolean = isNode(); public constructor(managementClient: TunnelManagementClient, trace?: Trace) { super(TunnelAccessScopes.Host, trace, managementClient); const publicKey = SshAlgorithms.publicKey.ecdsaSha2Nistp384!; if (publicKey) { this.hostPrivateKeyPromise = publicKey.generateKeyPair(); } } /** * A value indicating whether the port-forwarding service forwards connections to local TCP sockets. * Forwarded connections are not possible if the host is not NodeJS (e.g. browser). * The default value for NodeJS hosts is true. */ public get forwardConnectionsToLocalPorts(): boolean { return this.forwardConnectionsToLocalPortsValue; } public set forwardConnectionsToLocalPorts(value: boolean) { if (value === this.forwardConnectionsToLocalPortsValue) { return; } if (value && !isNode()) { throw new Error('Cannot forward connections to local TCP sockets on this platform.'); } this.forwardConnectionsToLocalPortsValue = value; } /** * Connects to a tunnel as a host and starts accepting incoming connections * to local ports as defined on the tunnel. * @deprecated Use `connect()` instead. */ public async start(tunnel: Tunnel): Promise { await this.connect(tunnel); } /** * Connects to a tunnel as a host and starts accepting incoming connections * to local ports as defined on the tunnel. */ public async connect( tunnel: Tunnel, options?: TunnelConnectionOptions, cancellation?: CancellationToken, ): Promise { await this.connectTunnelSession(tunnel, options, cancellation); } public refreshPorts(): Promise { // This is implemented by the derived class. return Promise.resolve(); } protected async forwardPort(pfs: PortForwardingService, port: TunnelPort) { const portNumber = Number(port.portNumber); if (pfs.localForwardedPorts.find((p) => p.localPort === portNumber)) { // The port is already forwarded. This may happen if we try to add the same port twice after reconnection. return; } // When forwarding from a Remote port we assume that the RemotePortNumber // and requested LocalPortNumber are the same. const forwarder = await pfs.forwardFromRemotePort( this.loopbackIp, portNumber, 'localhost', portNumber, ); if (!forwarder) { // The forwarding request was rejected by the client. return; } const key = new SessionPortKey(pfs.session.sessionId, Number(forwarder.localPort)); this.remoteForwarders.set(key.toString(), forwarder); } /** * Validate the {@link tunnel} and get data needed to connect to it, if the tunnel is provided; * otherwise, ensure that there is already sufficient data to connect to a tunnel. * @internal */ public async onConnectingToTunnel(): Promise { if (this.hostPrivateKey && this.hostPublicKeys) { return; } if (!this.tunnel) { throw new Error('Tunnel is required'); } if (!this.hostPrivateKeyPromise) { throw new Error('Cannot create host keys'); } this.hostPrivateKey = await this.hostPrivateKeyPromise; const buffer = await this.hostPrivateKey.getPublicKeyBytes( this.hostPrivateKey.keyAlgorithmName, ); if (!buffer) { throw new Error('Host private key public key bytes is not initialized'); } this.hostPublicKeys = [buffer.toString('base64')]; } } dev-tunnels-0.0.25/ts/src/connections/tunnelRelaySessionClass.ts000066400000000000000000000076711450757157500250400ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { Tunnel, TunnelAccessScopes } from '@microsoft/dev-tunnels-contracts'; import { CancellationToken } from 'vscode-jsonrpc'; import { TunnelAccessTokenProperties } from '@microsoft/dev-tunnels-management'; import { CancellationError, ObjectDisposedError, Stream, TraceLevel } from '@microsoft/dev-tunnels-ssh'; import { TunnelRelayStreamFactory, DefaultTunnelRelayStreamFactory } from '.'; import { TunnelSession } from './tunnelSession'; import { IClientConfig } from 'websocket'; import { TunnelConnectionOptions } from './tunnelConnectionOptions'; import * as http from 'http'; type Constructor = new (...args: any[]) => T; /** * Tunnel relay mixin class that adds relay connection capability to descendants of TunnelSession. * @param base Base class constructor. * @param protocols Web socket sub-protocols. * @returns A class where createSessionStream() connects to tunnel relay. */ export function tunnelRelaySessionClass>( base: TBase, protocols: string[], ) { return class TunnelRelaySession extends base { /** * Tunnel relay URI. * @internal */ public relayUri?: string; /** * Gets or sets a factory for creating relay streams. */ public streamFactory: TunnelRelayStreamFactory = new DefaultTunnelRelayStreamFactory(); /** * Creates a stream to the tunnel. * @internal */ public async createSessionStream( options?: TunnelConnectionOptions, cancellation?: CancellationToken, ): Promise<{ stream: Stream, protocol: string }> { if (!this.relayUri) { throw new Error( 'Cannot create tunnel session stream. Tunnel relay endpoint URI is missing', ); } const name = this.tunnelAccessScope === TunnelAccessScopes.Connect ? 'client' : 'host'; const accessToken = this.validateAccessToken(); this.trace(TraceLevel.Info, 0, `Connecting to ${name} tunnel relay ${this.relayUri}`); this.trace(TraceLevel.Verbose, 0, `Sec-WebSocket-Protocol: ${protocols.join(', ')}`); if (accessToken) { const tokenTrace = TunnelAccessTokenProperties.getTokenTrace(accessToken); this.trace(TraceLevel.Verbose, 0, `Authorization: tunnel <${tokenTrace}>`); } const clientConfig: IClientConfig = { tlsOptions: { agent: this.httpAgent, }, }; const streamAndProtocol = await this.streamFactory.createRelayStream( this.relayUri, protocols, accessToken, clientConfig ); this.trace( TraceLevel.Verbose, 0, `Connected with subprotocol '${streamAndProtocol.protocol}'`); return streamAndProtocol; } /** * Connect to the tunnel session with the tunnel connector. * @param tunnel Tunnel to use for the connection. * Undefined if the connection information is already known and the tunnel is not needed. * Tunnel object to get the connection information from that tunnel. * @internal */ public async connectTunnelSession( tunnel?: Tunnel, options?: TunnelConnectionOptions, cancellation?: CancellationToken, ): Promise { try { await super.connectTunnelSession(tunnel, options, cancellation); } catch (e) { if (e instanceof CancellationError || e instanceof ObjectDisposedError) { throw e; } throw new Error('Failed to connect to tunnel relay. ' + e); } } }; } dev-tunnels-0.0.25/ts/src/connections/tunnelRelayStreamFactory.ts000066400000000000000000000015311450757157500251770ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { Stream } from '@microsoft/dev-tunnels-ssh'; import { IClientConfig } from 'websocket'; /** * Interface for a factory capable of creating streams to a tunnel relay. */ export interface TunnelRelayStreamFactory { /** * Creates a stream connected to a tunnel relay URI. * @param relayUri URI of the tunnel relay to connect to. * @param protocols Array of supported connection protocols (websocket sub-protocols). * @param accessToken Tunnel host access token, or null if anonymous. * @param clientConfig Client config for websocket. */ createRelayStream( relayUri: string, protocols: string[], accessToken?: string, clientConfig?: IClientConfig, ): Promise<{ stream: Stream, protocol: string }>; } dev-tunnels-0.0.25/ts/src/connections/tunnelRelayTunnelClient.ts000066400000000000000000000067601450757157500250310ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { TunnelConnectionMode, TunnelRelayTunnelEndpoint, } from '@microsoft/dev-tunnels-contracts'; import { CancellationToken } from 'vscode-jsonrpc'; import { TunnelManagementClient } from '@microsoft/dev-tunnels-management'; import { Stream, Trace } from '@microsoft/dev-tunnels-ssh'; import { TunnelClientBase, webSocketSubProtocol, webSocketSubProtocolv2 } from './tunnelClientBase'; import { tunnelRelaySessionClass } from './tunnelRelaySessionClass'; // Check for an environment variable to determine which protocol version to use. // By default, prefer V2 and fall back to V1. const protocolVersion = process?.env && process.env.DEVTUNNELS_PROTOCOL_VERSION; const connectionProtocols = protocolVersion === '1' ? [webSocketSubProtocol] : protocolVersion === '2' ? [webSocketSubProtocolv2] : [webSocketSubProtocolv2, webSocketSubProtocol]; /** * Tunnel client implementation that connects via a tunnel relay. */ export class TunnelRelayTunnelClient extends tunnelRelaySessionClass( TunnelClientBase, connectionProtocols, ) { public static readonly webSocketSubProtocol = webSocketSubProtocol; public static readonly webSocketSubProtocolv2 = webSocketSubProtocolv2; public connectionModes: TunnelConnectionMode[] = []; public constructor(trace?: Trace, managementClient?: TunnelManagementClient) { super(trace, managementClient); } protected tunnelChanged() { super.tunnelChanged(); if (!this.tunnel) { this.relayUri = undefined; } else { if (!this.endpoints || this.endpoints.length === 0) { throw new Error('No hosts are currently accepting connections for the tunnel.'); } const tunnelEndpoints: TunnelRelayTunnelEndpoint[] = this.endpoints.filter( (ep) => ep.connectionMode === TunnelConnectionMode.TunnelRelay, ); if (tunnelEndpoints.length === 0) { throw new Error('The host is not currently accepting Tunnel relay connections.'); } // TODO: What if there are multiple relay endpoints, which one should the tunnel client pick, or is this an error? // For now, just chose the first one. const endpoint = tunnelEndpoints[0]; this.hostPublicKeys = endpoint.hostPublicKeys; this.relayUri = endpoint.clientRelayUri!; } } /** * Connect to the tunnel session on the relay service using the given access token for authorization. */ protected async connectClientToRelayServer( clientRelayUri: string, accessToken?: string, ): Promise { if (!clientRelayUri) { throw new Error('Client relay URI must be a non-empty string'); } this.relayUri = clientRelayUri; this.accessToken = accessToken; await this.connectTunnelSession(); } /** * Configures the tunnel session with the given stream. * @internal */ public async configureSession( stream: Stream, protocol: string, isReconnect: boolean, cancellation: CancellationToken, ): Promise { this.connectionProtocol = protocol; if (isReconnect && this.sshSession && !this.sshSession.isClosed) { await this.sshSession.reconnect(stream, cancellation); } else { await this.startSshSession(stream, cancellation); } } } dev-tunnels-0.0.25/ts/src/connections/tunnelRelayTunnelHost.ts000066400000000000000000000527231450757157500245300ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { TunnelConnectionMode, TunnelProtocol, TunnelRelayTunnelEndpoint, } from '@microsoft/dev-tunnels-contracts'; import { TunnelManagementClient } from '@microsoft/dev-tunnels-management'; import { SshChannelOpeningEventArgs, SshChannelOpenFailureReason, SshStream, SshSessionClosedEventArgs, SshDisconnectReason, TraceLevel, SshServerSession, SshAuthenticatingEventArgs, NodeStream, SshAuthenticationType, PromiseCompletionSource, CancellationError, Trace, SshChannel, Stream, SessionRequestMessage, SshRequestEventArgs, SessionRequestSuccessMessage, SshClientSession, SshSessionConfiguration, SshAlgorithms, SshSession, SshServerCredentials, SecureStream, SshProtocolExtensionNames, } from '@microsoft/dev-tunnels-ssh'; import { ForwardedPortConnectingEventArgs, PortForwardChannelOpenMessage, PortForwardingService, } from '@microsoft/dev-tunnels-ssh-tcp'; import { CancellationToken, Disposable } from 'vscode-jsonrpc'; import { SshHelpers } from './sshHelpers'; import { MultiModeTunnelHost } from './multiModeTunnelHost'; import { TunnelHostBase } from './tunnelHostBase'; import { tunnelRelaySessionClass } from './tunnelRelaySessionClass'; import { SessionPortKey } from './sessionPortKey'; import { PortRelayConnectRequestMessage } from './messages/portRelayConnectRequestMessage'; import { PortRelayConnectResponseMessage } from './messages/portRelayConnectResponseMessage'; import { v4 as uuidv4 } from 'uuid'; const webSocketSubProtocol = 'tunnel-relay-host'; const webSocketSubProtocolv2 = 'tunnel-relay-host-v2-dev'; // Check for an environment variable to determine which protocol version to use. // By default, prefer V2 and fall back to V1. const protocolVersion = process?.env && process.env.DEVTUNNELS_PROTOCOL_VERSION; const connectionProtocols = protocolVersion === '1' ? [webSocketSubProtocol] : protocolVersion === '2' ? [webSocketSubProtocolv2] : [webSocketSubProtocolv2, webSocketSubProtocol]; /** * Tunnel host implementation that uses data-plane relay * to accept client connections. */ export class TunnelRelayTunnelHost extends tunnelRelaySessionClass( TunnelHostBase, connectionProtocols, ) { public static readonly webSocketSubProtocol = webSocketSubProtocol; public static readonly webSocketSubProtocolv2 = webSocketSubProtocolv2; /** * Ssh channel type in host relay ssh session where client session streams are passed. */ public static clientStreamChannelType: string = 'client-ssh-session-stream'; private readonly id: string; private readonly hostId: string; private readonly clientSessionPromises: Promise[] = []; private readonly reconnectableSessions: SshServerSession[] = []; public constructor(managementClient: TunnelManagementClient, trace?: Trace) { super(managementClient, trace); this.hostId = MultiModeTunnelHost.hostId; this.id = uuidv4(); } /** * Configures the tunnel session with the given stream. * @internal */ public async configureSession( stream: Stream, protocol: string, isReconnect: boolean, cancellation: CancellationToken, ): Promise { this.connectionProtocol = protocol; if (this.connectionProtocol === webSocketSubProtocol) { // The V1 protocol always configures no security, equivalent to SSH MultiChannelStream. // The websocket transport is still encrypted and authenticated. this.sshSession = new SshClientSession( new SshSessionConfiguration(false)); // no encryption } else { this.sshSession = SshHelpers.createSshClientSession((config) => { // The V2 protocol configures optional encryption, including "none" as an enabled // and preferred key-exchange algorithm, because encryption of the outer SSH // session is optional since it is already over a TLS websocket. config.keyExchangeAlgorithms.splice(0, 0, SshAlgorithms.keyExchange.none); config.addService(PortForwardingService); }); const hostPfs = this.sshSession.activateService(PortForwardingService); hostPfs.messageFactory = this; hostPfs.onForwardedPortConnecting((e) => this.onForwardedPortConnecting(e)); } const channelOpenEventRegistration = this.sshSession.onChannelOpening((e) => { this.hostSession_ChannelOpening(this.sshSession!, e); }); const closeEventRegistration = this.sshSession.onClosed((e) => { this.hostSession_Closed(e, channelOpenEventRegistration, closeEventRegistration); }); this.sshSession.trace = this.trace; await this.sshSession.connect(stream, cancellation); // SSH authentication is skipped in V1 protocol, optional in V2 depending on whether the // session performed a key exchange (as indicated by having a session ID or not). In the // latter case a password is not required. Strong authentication was already handled by // the relay service via the tunnel access token used for the websocket connection. if (this.sshSession.sessionId) { await this.sshSession.authenticate({ username: 'tunnel' }); } if (this.connectionProtocol === webSocketSubProtocolv2) { // In the v2 protocol, the host starts "forwarding" the ports as soon as it connects. // Then the relay will forward the forwarded ports to clients as they connect. await this.startForwardingExistingPorts(this.sshSession); } } public async onConnectingToTunnel(): Promise { await super.onConnectingToTunnel(); if (!this.relayUri) { if (!this.tunnel) { throw new Error('Tunnel is required'); } let endpoint: TunnelRelayTunnelEndpoint = { id: this.id, hostId: this.hostId, hostPublicKeys: this.hostPublicKeys, connectionMode: TunnelConnectionMode.TunnelRelay, }; let additionalQueryParameters = undefined; if (this.tunnel.ports != null && this.tunnel.ports.find((v) => v.protocol === TunnelProtocol.Ssh)) { additionalQueryParameters = { includeSshGatewayPublicKey: 'true' }; } endpoint = await this.managementClient!.updateTunnelEndpoint(this.tunnel, endpoint, { additionalQueryParameters: additionalQueryParameters, }); this.relayUri = endpoint.hostRelayUri!; } } private hostSession_ChannelOpening(sender: SshClientSession, e: SshChannelOpeningEventArgs) { if (!e.isRemoteRequest) { // Auto approve all local requests (not that there are any for the time being). return; } if (this.connectionProtocol === webSocketSubProtocolv2 && e.channel.channelType === 'forwarded-tcpip' ) { // With V2 protocol, the relay server always sends an extended channel open message // with a property indicating whether E2E encryption is requested for the connection. // The host returns an extended response message indicating if E2EE is enabled. const relayRequestMessage = e.channel.openMessage .convertTo(new PortRelayConnectRequestMessage()); const responseMessage = new PortRelayConnectResponseMessage(); // The host can enable encryption for the channel if the client requested it. responseMessage.isE2EEncryptionEnabled = this.enableE2EEncryption && relayRequestMessage.isE2EEncryptionRequested; // In the future the relay might send additional information in the connect // request message, for example a user identifier that would enable the host to // group channels by user. e.openingPromise = Promise.resolve(responseMessage); return; } else if (e.channel.channelType !== TunnelRelayTunnelHost.clientStreamChannelType) { e.failureDescription = `Unknown channel type: ${e.channel.channelType}`; e.failureReason = SshChannelOpenFailureReason.unknownChannelType; return; } // V1 protocol. // Increase max window size to work around channel congestion bug. // This does not entirely eliminate the problem, but reduces the chance. e.channel.maxWindowSize = SshChannel.defaultMaxWindowSize * 5; if (this.isDisposed) { e.failureDescription = 'The host is disconnecting.'; e.failureReason = SshChannelOpenFailureReason.connectFailed; return; } const promise = this.acceptClientSession(e.channel, this.disposeToken); this.clientSessionPromises.push(promise); // eslint-disable-next-line @typescript-eslint/no-floating-promises promise.then(() => { const index = this.clientSessionPromises.indexOf(promise); this.clientSessionPromises.splice(index, 1); }); } protected onForwardedPortConnecting(e: ForwardedPortConnectingEventArgs): void { const channel = e.stream.channel; const relayRequestMessage = channel.openMessage.convertTo( new PortRelayConnectRequestMessage()); const isE2EEncryptionEnabled = this.enableE2EEncryption && relayRequestMessage.isE2EEncryptionRequested; if (isE2EEncryptionEnabled) { // Increase the max window size so that it is at least larger than the window // size of one client channel. channel.maxWindowSize = SshChannel.defaultMaxWindowSize * 2; const serverCredentials: SshServerCredentials = { publicKeys: [this.hostPrivateKey!] }; const secureStream = new SecureStream( e.stream, serverCredentials, this.reconnectableSessions); secureStream.trace = this.trace; // The client was already authenticated by the relay. secureStream.onAuthenticating((authEvent) => authEvent.authenticationPromise = Promise.resolve({})); // The client will connect to the secure stream after the channel is opened. secureStream.connect().catch((err) => { this.trace(TraceLevel.Error, 0, `Error connecting encrypted channel: ${err}`); }); e.transformPromise = Promise.resolve(secureStream); } super.onForwardedPortConnecting(e); } private async acceptClientSession( clientSessionChannel: SshChannel, cancellation: CancellationToken, ): Promise { try { const stream = new SshStream(clientSessionChannel); await this.connectAndRunClientSession(stream, cancellation); } catch (ex) { if (!(ex instanceof CancellationError) || !cancellation.isCancellationRequested) { this.trace(TraceLevel.Error, 0, `Error running client SSH session: ${ex}`); } } } /** * Creates an SSH server session for a client (V1 protocol), runs the session, * and waits for it to close. */ private async connectAndRunClientSession( stream: SshStream, cancellation: CancellationToken, ): Promise { if (cancellation.isCancellationRequested) { stream.destroy(); throw new CancellationError(); } const session = SshHelpers.createSshServerSession(this.reconnectableSessions, (config) => { config.protocolExtensions.push(SshProtocolExtensionNames.sessionReconnect); config.addService(PortForwardingService); }); session.trace = this.trace; session.credentials = { publicKeys: [this.hostPrivateKey!], }; const tcs = new PromiseCompletionSource(); const authenticatingEventRegistration = session.onAuthenticating((e) => { this.onSshClientAuthenticating(e); }); session.onClientAuthenticated(() => { // This call is async and will catch and log any async errors. void this.onSshClientAuthenticated(session); }); const requestRegistration = session.onRequest((e) => { this.onSshSessionRequest(e, session); }); const channelOpeningEventRegistration = session.onChannelOpening((e) => { this.onSshChannelOpening(e, session); }); const closedEventRegistration = session.onClosed((e) => { this.session_Closed(session, e, cancellation); tcs.resolve(); }); try { const nodeStream = new NodeStream(stream); await session.connect(nodeStream); this.sshSessions.push(session); cancellation.onCancellationRequested((e) => { tcs.reject(new CancellationError()); }); await tcs.promise; } finally { authenticatingEventRegistration.dispose(); requestRegistration.dispose(); channelOpeningEventRegistration.dispose(); closedEventRegistration.dispose(); await session.close(SshDisconnectReason.byApplication); session.dispose(); } } private onSshClientAuthenticating(e: SshAuthenticatingEventArgs) { if (e.authenticationType === SshAuthenticationType.clientNone) { // For now, the client is allowed to skip SSH authentication; // they must have a valid tunnel access token already to get this far. e.authenticationPromise = Promise.resolve({}); } else { // Other authentication types are not implemented. Doing nothing here // results in a client authentication failure. } } private async onSshClientAuthenticated(session: SshServerSession) { void this.startForwardingExistingPorts(session); } private async startForwardingExistingPorts(session: SshSession): Promise { const pfs = session.activateService(PortForwardingService); pfs.forwardConnectionsToLocalPorts = this.forwardConnectionsToLocalPorts; // Ports must be forwarded sequentially because the TS SSH lib // does not yet support concurrent requests. for (const port of this.tunnel?.ports ?? []) { this.trace(TraceLevel.Verbose, 0, `Forwarding port ${port.portNumber}`); try { await this.forwardPort(pfs, port); } catch (ex) { this.traceError(`Error forwarding port ${port.portNumber}: ${ex}`); } } } private onSshSessionRequest(e: SshRequestEventArgs, session: any) { if (e.requestType === 'RefreshPorts') { e.responsePromise = (async () => { await this.refreshPorts(); return new SessionRequestSuccessMessage(); })(); } } private onSshChannelOpening(e: SshChannelOpeningEventArgs, session: any) { if (!(e.request instanceof PortForwardChannelOpenMessage)) { // This is to let the Go SDK open an unused session channel if (e.request.channelType === SshChannel.sessionChannelType) { return; } this.trace( TraceLevel.Warning, 0, 'Rejecting request to open non-portforwarding channel.', ); e.failureReason = SshChannelOpenFailureReason.administrativelyProhibited; return; } const portForwardRequest = e.request as PortForwardChannelOpenMessage; if (portForwardRequest.channelType === 'direct-tcpip') { if (!this.tunnel!.ports!.some((p) => p.portNumber === portForwardRequest.port)) { this.trace( TraceLevel.Warning, 0, 'Rejecting request to connect to non-forwarded port:' + portForwardRequest.port, ); e.failureReason = SshChannelOpenFailureReason.administrativelyProhibited; } } else if (portForwardRequest.channelType === 'forwarded-tcpip') { const eventArgs = new ForwardedPortConnectingEventArgs( portForwardRequest.port, false, new SshStream(e.channel)); super.onForwardedPortConnecting(eventArgs); } else { // For forwarded-tcpip do not check remoteForwarders because they may not be updated yet. // There is a small time interval in forwardPort() between the port // being forwarded with forwardFromRemotePort and remoteForwarders updated. // Setting PFS.acceptRemoteConnectionsForNonForwardedPorts to false makes PFS reject forwarding requests from the // clients for the ports that are not forwarded and are missing in PFS.remoteConnectors. // Call to pfs.forwardFromRemotePort() in forwardPort() adds the connector to PFS.remoteConnectors. this.trace( TraceLevel.Warning, 0, 'Nonrecognized channel type ' + portForwardRequest.channelType, ); e.failureReason = SshChannelOpenFailureReason.unknownChannelType; } } private session_Closed( session: SshServerSession, e: SshSessionClosedEventArgs, cancellation: CancellationToken, ) { // Reconnecting client session may cause the new session to close with 'None' reason. if (e.reason === SshDisconnectReason.byApplication) { this.traceInfo('Client ssh session closed.'); } else if (cancellation.isCancellationRequested) { this.traceInfo('Client ssh session cancelled.'); } else if (e.reason !== SshDisconnectReason.none) { this.traceError( `Client ssh session closed unexpectedly due to ${e.reason}, "${e.message}"\n${e.error}`, ); } for (const [key, forwarder] of this.remoteForwarders.entries()) { if (forwarder.session === session) { forwarder.dispose(); this.remoteForwarders.delete(key); } } const index = this.sshSessions.indexOf(session); if (index >= 0) { this.sshSessions.splice(index, 1); } } private hostSession_Closed( e: SshSessionClosedEventArgs, channelOpenEventRegistration: Disposable, closeEventRegistration: Disposable, ) { closeEventRegistration.dispose(); channelOpenEventRegistration.dispose(); this.sshSession = undefined; this.traceInfo( `Connection to host tunnel relay closed.${this.isDisposed ? '' : ' Reconnecting.'}`, ); if (e.reason === SshDisconnectReason.connectionLost) { this.startReconnectingIfNotDisposed(); } } public async refreshPorts(cancellation?: CancellationToken): Promise { if (!this.canRefreshTunnel) { return; } await this.refreshTunnel(cancellation); const ports = this.tunnel?.ports ?? []; let sessions: SshSession[] = this.sshSessions; if (this.connectionProtocol === webSocketSubProtocolv2 && this.sshSession) { // In the V2 protocol, ports are forwarded directly on the host session. // (But even when the host is V2, some clients may still connect with V1.) sessions = [...sessions, this.sshSession ]; } const forwardPromises: Promise[] = []; for (const port of ports) { for (const session of sessions.filter((s) => s.isConnected && s.sessionId)) { const key = new SessionPortKey(session.sessionId!, Number(port.portNumber)); const forwarder = this.remoteForwarders.get(key.toString()); if (!forwarder) { const pfs = session.getService(PortForwardingService)!; forwardPromises.push(this.forwardPort(pfs, port)); } } } for (const [key, forwarder] of Object.entries(this.remoteForwarders)) { if (!ports.some((p) => p.portNumber === forwarder.localPort)) { this.remoteForwarders.delete(key); forwarder.dispose(); } } await Promise.all(forwardPromises); } /** * Disposes this tunnel session, closing all client connections, the host SSH session, and deleting the endpoint. */ public async dispose(): Promise { await super.dispose(); const promises: Promise[] = Object.assign([], this.clientSessionPromises); // No new client session should be added because the channel requests are rejected when the tunnel host is disposed. this.clientSessionPromises.length = 0; if (this.tunnel) { const promise = this.managementClient!.deleteTunnelEndpoints( this.tunnel, this.hostId, TunnelConnectionMode.TunnelRelay, ); promises.push(promise); } for (const forwarder of this.remoteForwarders.values()) { forwarder.dispose(); } // When client session promises finish, they remove the sessions from this.sshSessions await Promise.all(promises); } } dev-tunnels-0.0.25/ts/src/connections/tunnelSession.ts000066400000000000000000000053301450757157500230430ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { Tunnel } from '@microsoft/dev-tunnels-contracts'; import { Stream, Trace, CancellationToken } from '@microsoft/dev-tunnels-ssh'; import { RetryingTunnelConnectionEventArgs } from './retryingTunnelConnectionEventArgs'; import { TunnelConnectionOptions } from './tunnelConnectionOptions'; import * as http from 'http'; /** * Tunnel session. */ export interface TunnelSession { /** * Gets the tunnel. */ tunnel: Tunnel | null; /** * Gets the trace source. */ trace: Trace; /** * Gets tunnel access scope for this tunnel session. */ tunnelAccessScope: string; /** * Gets the http agent for http requests. */ httpAgent?: http.Agent; /** * Validates tunnel access token if it's present. Returns the token. */ validateAccessToken(): string | undefined; /** * Notifies about a connection retry, giving the client a chance to delay or cancel it. */ onRetrying(event: RetryingTunnelConnectionEventArgs): void; /** * Validate {@link tunnel} and get data needed to connect to it, if the tunnel is provided; * otherwise, ensure that there is already sufficient data to connect to a tunnel. */ onConnectingToTunnel(): Promise; /** * Connect to the tunnel session by running the provided {@link action}. */ connectSession(action: () => Promise): Promise; /** * Connect to the tunnel session with the tunnel connector. * @param tunnel Tunnel to use for the connection. * Undefined if the connection information is already known and the tunnel is not needed. * Tunnel object to get the connection information from that tunnel. */ connectTunnelSession( tunnel?: Tunnel, options?: TunnelConnectionOptions, cancellation?: CancellationToken, ): Promise; /** * Creates a stream to the tunnel for the tunnel session. */ createSessionStream( options?: TunnelConnectionOptions, cancellation?: CancellationToken, ): Promise<{ stream: Stream, protocol: string }>; /** * Configures the tunnel session with the given stream. */ configureSession( stream: Stream, protocol: string, isReconnect: boolean, cancellation?: CancellationToken, ): Promise; /** * Closes the tunnel session. */ closeSession(error?: Error): Promise; /** * Refreshes the tunnel access token. This may be useful when the tunnel service responds with 401 Unauthorized. */ refreshTunnelAccessToken(cancellation?: CancellationToken): Promise; } dev-tunnels-0.0.25/ts/src/connections/tunnelSshSessionClass.ts000066400000000000000000000064441450757157500245160ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { ObjectDisposedError, SshChannelError, SshConnectionError, SshDisconnectReason, SshReconnectError, } from '@microsoft/dev-tunnels-ssh'; import { TunnelSession } from './tunnelSession'; import { TunnelConnection } from './tunnelConnection'; import { TunnelConnectionSession } from './tunnelConnectionSession'; type Constructor = new (...args: any[]) => T; type CloseableSshSession = { readonly isClosed: boolean; close(reason?: SshDisconnectReason, message?: string, error?: Error): Promise; dispose(): void; }; /** * Tunnel relay mixin class that adds sshSession property and implements closeSession(). * The class's dispose() closes the session, and onConnectingToTunnel() * throws if the session already exists when connecting to a new tunnel. * @param base Base class constructor. * @returns A class with sshSession property where closeSession() closes it and dispose() calls closeSession(). */ export function tunnelSshSessionClass< TSshSession extends CloseableSshSession, TBase extends Constructor = Constructor< TunnelConnectionSession > >(base: TBase) { return class SshTunnelSession extends base { /** * SSH session that is used to connect to the tunnel. * @internal */ public sshSession?: TSshSession; /** * Closes the tunnel SSH session. * @internal */ public async closeSession(error?: Error): Promise { await super.closeSession(error); if (!this.sshSession) { return; } if (!this.sshSession.isClosed) { let reason = SshDisconnectReason.byApplication; if (error) { if (error instanceof SshConnectionError) { reason = (error as SshConnectionError).reason ?? SshDisconnectReason.connectionLost; } else if ( error instanceof SshReconnectError || error instanceof SshChannelError ) { reason = SshDisconnectReason.protocolError; } else { reason = SshDisconnectReason.connectionLost; } } await this.sshSession.close(reason, undefined, error); } // Closing the SSH session does nothing if the session is in disconnected state, // which may happen for a reconnectable session when the connection drops. // Disposing of the session forces closing and frees up the resources. if (this.sshSession) { this.sshSession.dispose(); this.sshSession = undefined; } } /** * Disposes this tunnel session, closing the SSH session used for it. */ public async dispose(): Promise { await super.dispose(); try { await this.closeSession(this.disconnectError); } catch (e) { if (!(e instanceof ObjectDisposedError)) throw e; } } }; } dev-tunnels-0.0.25/ts/src/connections/utils.ts000066400000000000000000000051461450757157500213370ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { CancellationToken, CancellationError } from '@microsoft/dev-tunnels-ssh'; import { Disposable } from 'vscode-jsonrpc'; export class List { public static groupBy( list: { forEach(f: (item: T) => void): void }, keyGetter: (item: T) => K, ): Map { const map = new Map(); list.forEach((item: T) => { const key = keyGetter(item); const collection = map.get(key); if (!collection) { map.set(key, [item]); } else { collection.push(item); } }); return map; } } /** * Resolves the promise after {@link milliseconds} have passed, or reject if {@link cancellation} is canceled. */ export function delay(milliseconds: number, cancellation?: CancellationToken): Promise { return new Promise((resolve, reject) => { let cancellationDisposable: Disposable | undefined; let timeout: NodeJS.Timeout | undefined = undefined; if (cancellation) { if (cancellation.isCancellationRequested) { reject(new CancellationError()); return; } cancellationDisposable = cancellation.onCancellationRequested(() => { if (timeout) { clearTimeout(timeout); } cancellationDisposable?.dispose(); reject(new CancellationError()); }); } timeout = setTimeout(() => { cancellationDisposable?.dispose(); resolve(); }, milliseconds); }); } /** * Gets the error message. */ export function getErrorMessage(e: any) { return String(e?.message ?? e); } /** * Wraps e in Error object if e is not Error. If e is Error, returns e as is. */ export function getError(e: any, messagePrefix?: string): Error { return e instanceof Error ? e : new Error(`${messagePrefix ?? ''}${e}`); } /** * Races a promise and cancellation. */ export function withCancellation( promise: Promise, cancellation?: CancellationToken, ): Promise { if (!cancellation) { return promise; } return Promise.race([ promise, new Promise((resolve, reject) => { if (cancellation.isCancellationRequested) { reject(new CancellationError()); } else { cancellation.onCancellationRequested(() => { reject(new CancellationError()); }); } }), ]); } dev-tunnels-0.0.25/ts/src/contracts/000077500000000000000000000000001450757157500172775ustar00rootroot00000000000000dev-tunnels-0.0.25/ts/src/contracts/LICENSE000066400000000000000000000021651450757157500203100ustar00rootroot00000000000000 MIT License Copyright (c) Microsoft Corporation. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE dev-tunnels-0.0.25/ts/src/contracts/README.md000066400000000000000000000001151450757157500205530ustar00rootroot00000000000000# Visual Studio Tunnels Contracts Library Tunnels contracts library for node dev-tunnels-0.0.25/ts/src/contracts/clusterDetails.ts000066400000000000000000000012401450757157500226330ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/ClusterDetails.cs /* eslint-disable */ /** * Details of a tunneling service cluster. Each cluster represents an instance of the * tunneling service running in a particular Azure region. New tunnels are created in the * current region unless otherwise specified. */ export interface ClusterDetails { /** * A cluster identifier based on its region. */ clusterId: string; /** * The URI of the service cluster. */ uri: string; /** * The Azure location of the cluster. */ azureLocation: string; } dev-tunnels-0.0.25/ts/src/contracts/errorCodes.ts000066400000000000000000000010351450757157500217550ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/ErrorCodes.cs /* eslint-disable */ /** * Error codes for ErrorDetail.Code and `x-ms-error-code` header. */ export enum ErrorCodes { /** * Operation timed out. */ Timeout = 'Timeout', /** * Operation cannot be performed because the service is not available. */ ServiceUnavailable = 'ServiceUnavailable', /** * Internal error. */ InternalError = 'InternalError', } dev-tunnels-0.0.25/ts/src/contracts/errorDetail.ts000066400000000000000000000016001450757157500221200ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/ErrorDetail.cs /* eslint-disable */ import { InnerErrorDetail } from './innerErrorDetail'; /** * The top-level error object whose code matches the x-ms-error-code response header */ export interface ErrorDetail { /** * One of a server-defined set of error codes defined in {@link ErrorCodes}. */ code: string; /** * A human-readable representation of the error. */ message: string; /** * The target of the error. */ target?: string; /** * An array of details about specific errors that led to this reported error. */ details?: ErrorDetail[]; /** * An object containing more specific information than the current object about the * error. */ innererror?: InnerErrorDetail; } dev-tunnels-0.0.25/ts/src/contracts/index.ts000066400000000000000000000021061450757157500207550ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. export { Tunnel } from './tunnel'; export { NamedRateStatus } from './namedRateStatus'; export { ProblemDetails } from './problemDetails'; export { TunnelAccessControl } from './tunnelAccessControl'; export { TunnelAccessControlEntry } from './tunnelAccessControlEntry'; export { TunnelAccessControlEntryType } from './tunnelAccessControlEntryType'; export { TunnelAccessScopes } from './tunnelAccessScopes'; export { TunnelConnectionMode } from './tunnelConnectionMode'; export { TunnelEndpoint } from './tunnelEndpoint'; export { TunnelHeaderNames } from './tunnelHeaderNames'; export { TunnelOptions } from './tunnelOptions'; export { TunnelPort } from './tunnelPort'; export { TunnelPortStatus } from './tunnelPortStatus'; export { TunnelProtocol } from './tunnelProtocol'; export { TunnelRelayTunnelEndpoint } from './tunnelRelayTunnelEndpoint'; export { TunnelServiceProperties } from './tunnelServiceProperties'; export { TunnelStatus } from './tunnelStatus'; export { ClusterDetails } from './clusterDetails'; dev-tunnels-0.0.25/ts/src/contracts/innerErrorDetail.ts000066400000000000000000000011741450757157500231220ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/InnerErrorDetail.cs /* eslint-disable */ /** * An object containing more specific information than the current object about the error. */ export interface InnerErrorDetail { /** * A more specific error code than was provided by the containing error. One of a * server-defined set of error codes in {@link ErrorCodes}. */ code: string; /** * An object containing more specific information than the current object about the * error. */ innererror?: InnerErrorDetail; } dev-tunnels-0.0.25/ts/src/contracts/localNetworkTunnelEndpoint.ts000066400000000000000000000022231450757157500252010ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/LocalNetworkTunnelEndpoint.cs /* eslint-disable */ import { TunnelEndpoint } from './tunnelEndpoint'; /** * Parameters for connecting to a tunnel via a local network connection. * * While a direct connection is technically not "tunneling", tunnel hosts may accept * connections via the local network as an optional more-efficient alternative to a relay. */ export interface LocalNetworkTunnelEndpoint extends TunnelEndpoint { /** * Gets or sets a list of IP endpoints where the host may accept connections. * * A host may accept connections on multiple IP endpoints simultaneously if there are * multiple network interfaces on the host system and/or if the host supports both * IPv4 and IPv6. Each item in the list is a URI consisting of a scheme (which gives * an indication of the network connection protocol), an IP address (IPv4 or IPv6) and * a port number. The URIs do not typically include any paths, because the connection * is not normally HTTP-based. */ hostEndpoints: string[]; } dev-tunnels-0.0.25/ts/src/contracts/namedRateStatus.ts000066400000000000000000000005621450757157500227560ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/NamedRateStatus.cs /* eslint-disable */ import { RateStatus } from './rateStatus'; /** * A named {@link RateStatus}. */ export interface NamedRateStatus extends RateStatus { /** * The name of the rate status. */ name?: string; } dev-tunnels-0.0.25/ts/src/contracts/package.json000066400000000000000000000010001450757157500215540ustar00rootroot00000000000000{ "name": "@microsoft/dev-tunnels-contracts", "version": "", "description": "Tunnels library for Visual Studio tools", "keywords": [ "Tunnels" ], "author": "Microsoft", "license": "MIT", "scripts": { "compile": "npm run -C ../.. compile", "eslint": "npm run -C ../.. eslint", "eslint-fix": "npm run -C ../.. eslint-fix", "watch": "npm run -C ../.. watch .", "test": "npm run -C ../.. test" }, "dependencies": { "buffer": "^5.2.1", "debug": "^4.1.1", "vscode-jsonrpc": "^4.0.0" } } dev-tunnels-0.0.25/ts/src/contracts/problemDetails.ts000066400000000000000000000016201450757157500226140ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/ProblemDetails.cs /* eslint-disable */ /** * Structure of error details returned by the tunnel service, including validation errors. * * This object may be returned with a response status code of 400 (or other 4xx code). It * is compatible with RFC 7807 Problem Details (https://tools.ietf.org/html/rfc7807) and * https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.problemdetails but * doesn't require adding a dependency on that package. */ export interface ProblemDetails { /** * Gets or sets the error title. */ title?: string; /** * Gets or sets the error detail. */ detail?: string; /** * Gets or sets additional details about individual request properties. */ errors?: { [property: string]: string[] }; } dev-tunnels-0.0.25/ts/src/contracts/rateStatus.ts000066400000000000000000000014371450757157500220130ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/RateStatus.cs /* eslint-disable */ import { ResourceStatus } from './resourceStatus'; /** * Current value and limit information for a rate-limited operation related to a tunnel or * port. */ export interface RateStatus extends ResourceStatus { /** * Gets or sets the length of each period, in seconds, over which the rate is * measured. * * For rates that are limited by month (or billing period), this value may represent * an estimate, since the actual duration may vary by the calendar. */ periodSeconds?: number; /** * Gets or sets the unix time in seconds when this status will be reset. */ resetTime?: number; } dev-tunnels-0.0.25/ts/src/contracts/resourceStatus.ts000066400000000000000000000015541450757157500227070ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/ResourceStatus.cs /* eslint-disable */ /** * Current value and limit for a limited resource related to a tunnel or tunnel port. */ export interface ResourceStatus { /** * Gets or sets the current value. */ current: number; /** * Gets or sets the limit enforced by the service, or null if there is no limit. * * Any requests that would cause the limit to be exceeded may be denied by the * service. For HTTP requests, the response is generally a 403 Forbidden status, with * details about the limit in the response body. */ limit?: number; /** * Gets or sets an optional source of the {@link ResourceStatus.limit}, or null if * there is no limit. */ limitSource?: string; } dev-tunnels-0.0.25/ts/src/contracts/serviceVersionDetails.ts000066400000000000000000000015221450757157500241630ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/ServiceVersionDetails.cs /* eslint-disable */ /** * Data contract for service version details. */ export interface ServiceVersionDetails { /** * Gets or sets the version of the service. E.g. "1.0.6615.53976". The version * corresponds to the build number. */ version?: string; /** * Gets or sets the commit ID of the service. */ commitId?: string; /** * Gets or sets the commit date of the service. */ commitDate?: string; /** * Gets or sets the cluster ID of the service that handled the request. */ clusterId?: string; /** * Gets or sets the Azure location of the service that handled the request. */ azureLocation?: string; } dev-tunnels-0.0.25/ts/src/contracts/tsconfig.json000066400000000000000000000003741450757157500220120ustar00rootroot00000000000000{ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "../../out/lib/contracts", "tsBuildInfoFile": "../../out/lib/contracts/tsbuildinfo.json", "rootDir": "." }, "include": [ "**/*.ts", "package.json" ], "references": [] } dev-tunnels-0.0.25/ts/src/contracts/tunnel.ts000066400000000000000000000055121450757157500211570ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/Tunnel.cs /* eslint-disable */ import { TunnelAccessControl } from './tunnelAccessControl'; import { TunnelEndpoint } from './tunnelEndpoint'; import { TunnelOptions } from './tunnelOptions'; import { TunnelPort } from './tunnelPort'; import { TunnelStatus } from './tunnelStatus'; /** * Data contract for tunnel objects managed through the tunnel service REST API. */ export interface Tunnel { /** * Gets or sets the ID of the cluster the tunnel was created in. */ clusterId?: string; /** * Gets or sets the generated ID of the tunnel, unique within the cluster. */ tunnelId?: string; /** * Gets or sets the optional short name (alias) of the tunnel. * * The name must be globally unique within the parent domain, and must be a valid * subdomain. */ name?: string; /** * Gets or sets the description of the tunnel. */ description?: string; /** * Gets or sets the tags of the tunnel. */ tags?: string[]; /** * Gets or sets the optional parent domain of the tunnel, if it is not using the * default parent domain. */ domain?: string; /** * Gets or sets a dictionary mapping from scopes to tunnel access tokens. */ accessTokens?: { [scope: string]: string }; /** * Gets or sets access control settings for the tunnel. * * See {@link TunnelAccessControl} documentation for details about the access control * model. */ accessControl?: TunnelAccessControl; /** * Gets or sets default options for the tunnel. */ options?: TunnelOptions; /** * Gets or sets current connection status of the tunnel. */ status?: TunnelStatus; /** * Gets or sets an array of endpoints where hosts are currently accepting client * connections to the tunnel. */ endpoints?: TunnelEndpoint[]; /** * Gets or sets a list of ports in the tunnel. * * This optional property enables getting info about all ports in a tunnel at the same * time as getting tunnel info, or creating one or more ports at the same time as * creating a tunnel. It is omitted when listing (multiple) tunnels, or when updating * tunnel properties. (For the latter, use APIs to create/update/delete individual * ports instead.) */ ports?: TunnelPort[]; /** * Gets or sets the time in UTC of tunnel creation. */ created?: Date; /** * Gets or the time the tunnel will be deleted if it is not used or updated. */ expiration?: Date; /** * Gets or the custom amount of time the tunnel will be valid if it is not used or * updated in seconds. */ customExpiration?: number; } dev-tunnels-0.0.25/ts/src/contracts/tunnelAccessControl.ts000066400000000000000000000031611450757157500236400ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelAccessControl.cs /* eslint-disable */ import { TunnelAccessControlEntry } from './tunnelAccessControlEntry'; /** * Data contract for access control on a {@link Tunnel} or {@link TunnelPort}. * * Tunnels and tunnel ports can each optionally have an access-control property set on * them. An access-control object contains a list (ACL) of entries (ACEs) that specify the * access scopes granted or denied to some subjects. Tunnel ports inherit the ACL from the * tunnel, though ports may include ACEs that augment or override the inherited rules. * Currently there is no capability to define "roles" for tunnel access (where a role * specifies a set of related access scopes), and assign roles to users. That feature may * be added in the future. (It should be represented as a separate `RoleAssignments` * property on this class.) */ export interface TunnelAccessControl { /** * Gets or sets the list of access control entries. * * The order of entries is significant: later entries override earlier entries that * apply to the same subject. However, deny rules are always processed after allow * rules, therefore an allow rule cannot override a deny rule for the same subject. */ entries: TunnelAccessControlEntry[]; } // Import static members from a non-generated file, // and re-export them as an object with the same name as the interface. import { validateScopes, } from './tunnelAccessControlStatics'; export const TunnelAccessControl = { validateScopes, }; dev-tunnels-0.0.25/ts/src/contracts/tunnelAccessControlEntry.ts000066400000000000000000000115211450757157500246610ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelAccessControlEntry.cs /* eslint-disable */ import { TunnelAccessControlEntryType } from './tunnelAccessControlEntryType'; /** * Data contract for an access control entry on a {@link Tunnel} or {@link TunnelPort}. * * An access control entry (ACE) grants or denies one or more access scopes to one or more * subjects. Tunnel ports inherit access control entries from their tunnel, and they may * have additional port-specific entries that augment or override those access rules. */ export interface TunnelAccessControlEntry { /** * Gets or sets the access control entry type. */ type: TunnelAccessControlEntryType; /** * Gets or sets the provider of the subjects in this access control entry. The * provider impacts how the subject identifiers are resolved and displayed. The * provider may be an identity provider such as AAD, or a system or standard such as * "ssh" or "ipv4". * * For user, group, or org ACEs, this value is the name of the identity provider of * the user/group/org IDs. It may be one of the well-known provider names in {@link * TunnelAccessControlEntry.providers}, or (in the future) a custom identity provider. * For public key ACEs, this value is the type of public key, e.g. "ssh". For IP * address range ACEs, this value is the IP address version, "ipv4" or "ipv6", or * "service-tag" if the range is defined by an Azure service tag. For anonymous ACEs, * this value is null. */ provider?: string; /** * Gets or sets a value indicating whether this is an access control entry on a tunnel * port that is inherited from the tunnel's access control list. */ isInherited?: boolean; /** * Gets or sets a value indicating whether this entry is a deny rule that blocks * access to the specified users. Otherwise it is an allow rule. * * All deny rules (including inherited rules) are processed after all allow rules. * Therefore a deny ACE cannot be overridden by an allow ACE that is later in the list * or on a more-specific resource. In other words, inherited deny ACEs cannot be * overridden. */ isDeny?: boolean; /** * Gets or sets a value indicating whether this entry applies to all subjects that are * NOT in the {@link TunnelAccessControlEntry.subjects} list. * * Examples: an inverse organizations ACE applies to all users who are not members of * the listed organization(s); an inverse anonymous ACE applies to all authenticated * users; an inverse IP address ranges ACE applies to all clients that are not within * any of the listed IP address ranges. The inverse option is often useful in policies * in combination with {@link TunnelAccessControlEntry.isDeny}, for example a policy * could deny access to users who are not members of an organization or are outside of * an IP address range, effectively blocking any tunnels from allowing outside access * (because inherited deny ACEs cannot be overridden). */ isInverse?: boolean; /** * Gets or sets an optional organization context for all subjects of this entry. The * use and meaning of this value depends on the {@link TunnelAccessControlEntry.type} * and {@link TunnelAccessControlEntry.provider} of this entry. * * For AAD users and group ACEs, this value is the AAD tenant ID. It is not currently * used with any other types of ACEs. */ organization?: string; /** * Gets or sets the subjects for the entry, such as user or group IDs. The format of * the values depends on the {@link TunnelAccessControlEntry.type} and {@link * TunnelAccessControlEntry.provider} of this entry. */ subjects: string[]; /** * Gets or sets the access scopes that this entry grants or denies to the subjects. * * These must be one or more values from {@link TunnelAccessScopes}. */ scopes: string[]; /** * Gets or sets the expiration for an access control entry. * * If no value is set then this value is null. */ expiration?: Date; } namespace TunnelAccessControlEntry { /** * Constants for well-known identity providers. */ export enum Providers { /** * Microsoft (AAD) identity provider. */ Microsoft = 'microsoft', /** * GitHub identity provider. */ GitHub = 'github', /** * SSH public keys. */ Ssh = 'ssh', /** * IPv4 addresses. */ IPv4 = 'ipv4', /** * IPv6 addresses. */ IPv6 = 'ipv6', /** * Service tags. */ ServiceTag = 'service-tag', } } dev-tunnels-0.0.25/ts/src/contracts/tunnelAccessControlEntryType.ts000066400000000000000000000032241450757157500255240ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelAccessControlEntryType.cs /* eslint-disable */ /** * Specifies the type of {@link TunnelAccessControlEntry}. */ export enum TunnelAccessControlEntryType { /** * Uninitialized access control entry type. */ None = 'None', /** * The access control entry refers to all anonymous users. */ Anonymous = 'Anonymous', /** * The access control entry is a list of user IDs that are allowed (or denied) access. */ Users = 'Users', /** * The access control entry is a list of groups IDs that are allowed (or denied) * access. */ Groups = 'Groups', /** * The access control entry is a list of organization IDs that are allowed (or denied) * access. * * All users in the organizations are allowed (or denied) access, unless overridden by * following group or user rules. */ Organizations = 'Organizations', /** * The access control entry is a list of repositories. Users are allowed access to the * tunnel if they have access to the repo. */ Repositories = 'Repositories', /** * The access control entry is a list of public keys. Users are allowed access if they * can authenticate using a private key corresponding to one of the public keys. */ PublicKeys = 'PublicKeys', /** * The access control entry is a list of IP address ranges that are allowed (or * denied) access to the tunnel. Ranges can be IPv4, IPv6, or Azure service tags. */ IPAddressRanges = 'IPAddressRanges', } dev-tunnels-0.0.25/ts/src/contracts/tunnelAccessControlStatics.ts000066400000000000000000000032031450757157500251700ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { TunnelAccessScopes } from './tunnelAccessScopes'; const allScopes = [ TunnelAccessScopes.Manage, TunnelAccessScopes.ManagePorts, TunnelAccessScopes.Host, TunnelAccessScopes.Inspect, TunnelAccessScopes.Connect, ]; /** * Checks that all items in an array of scopes are valid. * @param scopes List of scopes to validate. * @param validScopes Optional subset of scopes to be considered valid; * if omitted then all defined scopes are valid. * @param allowMultiple Whether to allow multiple space-delimited scopes in a single item. * Multiple scopes are supported when requesting a tunnel access token with a combination of scopes. * @throws Error if a scope is not valid. */ export function validateScopes( scopes: string[], validScopes?: string[], allowMultiple?: boolean, ): void { if (!Array.isArray(scopes)) { throw new TypeError('A scopes array was expected.'); } if (allowMultiple) { scopes = scopes.map((s) => s.split(' ')).reduce((a, b) => a.concat(b), []); } scopes.forEach((scope) => { if (!scope) { throw new Error('Tunnel access scopes include a null/empty item.'); } else if (!allScopes.includes(scope)) { throw new Error('Invalid tunnel access scope: ' + scope); } }); if (Array.isArray(validScopes)) { scopes.forEach((scope) => { if (!validScopes.includes(scope)) { throw new Error('Tunnel access scope is invalid for current request: scope'); } }); } } dev-tunnels-0.0.25/ts/src/contracts/tunnelAccessScopes.ts000066400000000000000000000027261450757157500234620ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelAccessScopes.cs /* eslint-disable */ /** * Defines scopes for tunnel access tokens. * * A tunnel access token with one or more of these scopes typically also has cluster ID * and tunnel ID claims that limit the access scope to a specific tunnel, and may also * have one or more port claims that further limit the access to particular ports of the * tunnel. */ export enum TunnelAccessScopes { /** * Allows creating tunnels. This scope is valid only in policies at the global, * domain, or organization level; it is not relevant to an already-created tunnel or * tunnel port. (Creation of ports requires "manage" or "host" access to the tunnel.) */ Create = 'create', /** * Allows management operations on tunnels and tunnel ports. */ Manage = 'manage', /** * Allows management operations on all ports of a tunnel, but does not allow updating * any other tunnel properties or deleting the tunnel. */ ManagePorts = 'manage:ports', /** * Allows accepting connections on tunnels as a host. Includes access to update tunnel * endpoints and ports. */ Host = 'host', /** * Allows inspecting tunnel connection activity and data. */ Inspect = 'inspect', /** * Allows connecting to tunnels or ports as a client. */ Connect = 'connect', } dev-tunnels-0.0.25/ts/src/contracts/tunnelAccessSubject.ts000066400000000000000000000032161450757157500236200ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelAccessSubject.cs /* eslint-disable */ import { TunnelAccessControlEntryType } from './tunnelAccessControlEntryType'; /** * Properties about a subject of a tunnel access control entry (ACE), used when resolving * subject names to IDs when creating new ACEs, or formatting subject IDs to names when * displaying existing ACEs. */ export interface TunnelAccessSubject { /** * Gets or sets the type of subject, e.g. user, group, or organization. */ type: TunnelAccessControlEntryType; /** * Gets or sets the subject ID. * * The ID is typically a guid or integer that is unique within the scope of the * identity provider or organization, and never changes for that subject. */ id?: string; /** * Gets or sets the subject organization ID, which may be required if an organization * is not implied by the authentication context. */ organizationId?: string; /** * Gets or sets the partial or full subject name. * * When resolving a subject name to ID, a partial name may be provided, and the full * name is returned if the partial name was successfully resolved. When formatting a * subject ID to name, the full name is returned if the ID was found. */ name?: string; /** * Gets or sets an array of possible subject matches, if a partial name was provided * and did not resolve to a single subject. * * This property applies only when resolving subject names to IDs. */ matches?: TunnelAccessSubject[]; } dev-tunnels-0.0.25/ts/src/contracts/tunnelAuthenticationSchemes.ts000066400000000000000000000013161450757157500253650ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelAuthenticationSchemes.cs /* eslint-disable */ /** * Defines string constants for authentication schemes supported by tunnel service APIs. */ export enum TunnelAuthenticationSchemes { /** * Authentication scheme for AAD (or Microsoft account) access tokens. */ Aad = 'aad', /** * Authentication scheme for GitHub access tokens. */ GitHub = 'github', /** * Authentication scheme for tunnel access tokens. */ Tunnel = 'tunnel', /** * Authentication scheme for tunnelPlan access tokens. */ TunnelPlan = 'tunnelplan', } dev-tunnels-0.0.25/ts/src/contracts/tunnelConnectionMode.ts000066400000000000000000000014271450757157500240050ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelConnectionMode.cs /* eslint-disable */ /** * Specifies the connection protocol / implementation for a tunnel. * * Depending on the connection mode, hosts or clients might need to use different * authentication and connection protocols. */ export enum TunnelConnectionMode { /** * Connect directly to the host over the local network. * * While it's technically not "tunneling", this mode may be combined with others to * enable choosing the most efficient connection mode available. */ LocalNetwork = 'LocalNetwork', /** * Use the tunnel service's integrated relay function. */ TunnelRelay = 'TunnelRelay', } dev-tunnels-0.0.25/ts/src/contracts/tunnelConstraints.ts000066400000000000000000000214601450757157500234070ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelConstraints.cs /* eslint-disable */ /** * Tunnel constraints. */ namespace TunnelConstraints { /** * Min length of tunnel cluster ID. */ export const clusterIdMinLength: number = 3; /** * Max length of tunnel cluster ID. */ export const clusterIdMaxLength: number = 12; /** * Length of V1 tunnel id. */ export const oldTunnelIdLength: number = 8; /** * Min length of V2 tunnelId. */ export const newTunnelIdMinLength: number = 3; /** * Max length of V2 tunnelId. */ export const newTunnelIdMaxLength: number = 60; /** * Length of a tunnel alias. */ export const tunnelAliasLength: number = 8; /** * Min length of tunnel name. */ export const tunnelNameMinLength: number = 3; /** * Max length of tunnel name. */ export const tunnelNameMaxLength: number = 60; /** * Max length of tunnel or port description. */ export const descriptionMaxLength: number = 400; /** * Min length of a single tunnel or port tag. */ export const tagMinLength: number = 1; /** * Max length of a single tunnel or port tag. */ export const tagMaxLength: number = 50; /** * Maximum number of tags that can be applied to a tunnel or port. */ export const maxTags: number = 100; /** * Min length of a tunnel domain. */ export const tunnelDomainMinLength: number = 4; /** * Max length of a tunnel domain. */ export const tunnelDomainMaxLength: number = 180; /** * Maximum number of items allowed in the tunnel ports array. The actual limit on * number of ports that can be created may be much lower, and may depend on various * resource limitations or policies. */ export const tunnelMaxPorts: number = 1000; /** * Maximum number of access control entries (ACEs) in a tunnel or tunnel port access * control list (ACL). */ export const accessControlMaxEntries: number = 40; /** * Maximum number of subjects (such as user IDs) in a tunnel or tunnel port access * control entry (ACE). */ export const accessControlMaxSubjects: number = 100; /** * Max length of an access control subject or organization ID. */ export const accessControlSubjectMaxLength: number = 200; /** * Max length of an access control subject name, when resolving names to IDs. */ export const accessControlSubjectNameMaxLength: number = 200; /** * Maximum number of scopes in an access control entry. */ export const accessControlMaxScopes: number = 10; /** * Regular expression that can match or validate tunnel cluster ID strings. * * Cluster IDs are alphanumeric; hyphens are not permitted. */ export const clusterIdPattern: string = '^(([a-z]{3,4}[0-9]{1,3})|asse|aue|brs|euw|use)$'; /** * Regular expression that can match or validate tunnel cluster ID strings. * * Cluster IDs are alphanumeric; hyphens are not permitted. */ export const clusterIdRegex: RegExp = new RegExp(TunnelConstraints.clusterIdPattern); /** * Characters that are valid in tunnel IDs. Includes numbers and lowercase letters, * excluding vowels and 'y' (to avoid accidentally generating any random words). */ export const oldTunnelIdChars: string = '0123456789bcdfghjklmnpqrstvwxz'; /** * Regular expression that can match or validate tunnel ID strings. * * Tunnel IDs are fixed-length and have a limited character set of numbers and * lowercase letters (minus vowels and y). */ export const oldTunnelIdPattern: string = '[' + TunnelConstraints.oldTunnelIdChars + ']{8}'; /** * Regular expression that can match or validate tunnel ID strings. * * Tunnel IDs are fixed-length and have a limited character set of numbers and * lowercase letters (minus vowels and y). */ export const oldTunnelIdRegex: RegExp = new RegExp(TunnelConstraints.oldTunnelIdPattern); /** * Characters that are valid in tunnel IDs. Includes numbers and lowercase letters, * excluding vowels and 'y' (to avoid accidentally generating any random words). */ export const newTunnelIdChars: string = '0123456789abcdefghijklmnopqrstuvwxyz-'; /** * Regular expression that can match or validate tunnel ID strings. * * Tunnel IDs are fixed-length and have a limited character set of numbers and * lowercase letters (minus vowels and y). */ export const newTunnelIdPattern: string = '[a-z0-9][a-z0-9-]{1,58}[a-z0-9]'; /** * Regular expression that can match or validate tunnel ID strings. * * Tunnel IDs are fixed-length and have a limited character set of numbers and * lowercase letters (minus vowels and y). */ export const newTunnelIdRegex: RegExp = new RegExp(TunnelConstraints.newTunnelIdPattern); /** * Characters that are valid in tunnel IDs. Includes numbers and lowercase letters, * excluding vowels and 'y' (to avoid accidentally generating any random words). */ export const tunnelAliasChars: string = '0123456789bcdfghjklmnpqrstvwxz'; /** * Regular expression that can match or validate tunnel alias strings. * * Tunnel Aliases are fixed-length and have a limited character set of numbers and * lowercase letters (minus vowels and y). */ export const tunnelAliasPattern: string = '[' + TunnelConstraints.tunnelAliasChars + ']{3,60}'; /** * Regular expression that can match or validate tunnel alias strings. * * Tunnel Aliases are fixed-length and have a limited character set of numbers and * lowercase letters (minus vowels and y). */ export const tunnelAliasRegex: RegExp = new RegExp(TunnelConstraints.tunnelAliasPattern); /** * Regular expression that can match or validate tunnel names. * * Tunnel names are alphanumeric and may contain hyphens. The pattern also allows an * empty string because tunnels may be unnamed. */ export const tunnelNamePattern: string = '([a-z0-9][a-z0-9-]{1,58}[a-z0-9])|(^$)'; /** * Regular expression that can match or validate tunnel names. * * Tunnel names are alphanumeric and may contain hyphens. The pattern also allows an * empty string because tunnels may be unnamed. */ export const tunnelNameRegex: RegExp = new RegExp(TunnelConstraints.tunnelNamePattern); /** * Regular expression that can match or validate tunnel or port tags. */ export const tagPattern: string = '[\\w-=]{1,50}'; /** * Regular expression that can match or validate tunnel or port tags. */ export const tagRegex: RegExp = new RegExp(TunnelConstraints.tagPattern); /** * Regular expression that can match or validate tunnel domains. * * The tunnel service may perform additional contextual validation at the time the * domain is registered. */ export const tunnelDomainPattern: string = '[0-9a-z][0-9a-z-.]{1,158}[0-9a-z]|(^$)'; /** * Regular expression that can match or validate tunnel domains. * * The tunnel service may perform additional contextual validation at the time the * domain is registered. */ export const tunnelDomainRegex: RegExp = new RegExp(TunnelConstraints.tunnelDomainPattern); /** * Regular expression that can match or validate an access control subject or * organization ID. * * The : and / characters are allowed because subjects may include IP addresses and * ranges. The @ character is allowed because MSA subjects may be identified by email * address. */ export const accessControlSubjectPattern: string = '[0-9a-zA-Z-._:/@]{0,200}'; /** * Regular expression that can match or validate an access control subject or * organization ID. */ export const accessControlSubjectRegex: RegExp = new RegExp(TunnelConstraints.accessControlSubjectPattern); /** * Regular expression that can match or validate an access control subject name, when * resolving subject names to IDs. * * Note angle-brackets are only allowed when they wrap an email address as part of a * formatted name with email. The service will block any other use of angle-brackets, * to avoid any XSS risks. */ export const accessControlSubjectNamePattern: string = '[ \\w\\d-.,/\'"_@()<>]{0,200}'; /** * Regular expression that can match or validate an access control subject name, when * resolving subject names to IDs. */ export const accessControlSubjectNameRegex: RegExp = new RegExp(TunnelConstraints.accessControlSubjectNamePattern); } dev-tunnels-0.0.25/ts/src/contracts/tunnelEndpoint.ts000066400000000000000000000064001450757157500226550ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelEndpoint.cs /* eslint-disable */ import { TunnelConnectionMode } from './tunnelConnectionMode'; /** * Base class for tunnel connection parameters. * * A tunnel endpoint specifies how and where hosts and clients can connect to a tunnel. * There is a subclass for each connection mode, each having different connection * parameters. A tunnel may have multiple endpoints for one host (or multiple hosts), and * clients can select their preferred endpoint(s) from those depending on network * environment or client capabilities. */ export interface TunnelEndpoint { /** * Gets or sets the ID of this endpoint. */ id?: string; /** * Gets or sets the connection mode of the endpoint. * * This property is required when creating or updating an endpoint. The subclass type * is also an indication of the connection mode, but this property is necessary to * determine the subclass type when deserializing. */ connectionMode: TunnelConnectionMode; /** * Gets or sets the ID of the host that is listening on this endpoint. * * This property is required when creating or updating an endpoint. If the host * supports multiple connection modes, the host's ID is the same for all the endpoints * it supports. However different hosts may simultaneously accept connections at * different endpoints for the same tunnel, if enabled in tunnel options. */ hostId: string; /** * Gets or sets an array of public keys, which can be used by clients to authenticate * the host. */ hostPublicKeys?: string[]; /** * Gets or sets a string used to format URIs where a web client can connect to ports * of the tunnel. The string includes a {@link TunnelEndpoint.portToken} that must be * replaced with the actual port number. */ portUriFormat?: string; /** * Gets or sets the URI where a web client can connect to the default port of the * tunnel. */ tunnelUri?: string; /** * Gets or sets a string used to format ssh command where ssh client can connect to * shared ssh port of the tunnel. The string includes a {@link * TunnelEndpoint.portToken} that must be replaced with the actual port number. */ portSshCommandFormat?: string; /** * Gets or sets the Ssh command where the Ssh client can connect to the default ssh * port of the tunnel. */ tunnelSshCommand?: string; /** * Gets or sets the Ssh gateway public key which should be added to the * authorized_keys file so that tunnel service can connect to the shared ssh server. */ sshGatewayPublicKey?: string; } /** * Token included in {@link TunnelEndpoint.portUriFormat} and {@link * TunnelEndpoint.portSshCommandFormat} that is to be replaced by a specified port number. */ export const portToken = '{port}'; // Import static members from a non-generated file, // and re-export them as an object with the same name as the interface. import { getPortUri, getPortSshCommand, } from './tunnelEndpointStatics'; export const TunnelEndpoint = { portToken, getPortUri, getPortSshCommand, }; dev-tunnels-0.0.25/ts/src/contracts/tunnelEndpointStatics.ts000066400000000000000000000035241450757157500242140ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { TunnelEndpoint as ITunnelEndpoint, portToken } from './tunnelEndpoint'; /** * Gets a URI where a web client can connect to a tunnel port. * * Requests to the URI may result in HTTP 307 redirections, so the client may need to * follow the redirection in order to connect to the port. * * If the port is not currently shared via the tunnel, or if a host is not currently * connected to the tunnel, then requests to the port URI may result in a 502 Bad Gateway * response. * * @param endpoint The tunnel endpoint containing connection information. * @param portNumber The port number to connect to; the port is assumed to be * separately shared by a tunnel host. * @returns URI for the requested port, or `undefined` if the endpoint does not support * web client connections. */ export function getPortUri(endpoint: ITunnelEndpoint, portNumber?: number): string | undefined { if (!endpoint) { throw new TypeError('A tunnel endpoint is required.'); } if (typeof portNumber !== 'number' && !endpoint.tunnelUri) { return endpoint.tunnelUri; } if (typeof portNumber !== 'number' || !endpoint.portUriFormat) { return undefined; } return endpoint.portUriFormat.replace(portToken, portNumber.toString()); } export function getPortSshCommand( endpoint: ITunnelEndpoint, portNumber?: number, ): string | undefined { if (!endpoint) { throw new TypeError('A tunnel endpoint is required.'); } if (typeof portNumber !== 'number' && !endpoint.tunnelSshCommand) { return endpoint.tunnelSshCommand; } if (typeof portNumber !== 'number' || !endpoint.portSshCommandFormat) { return undefined; } return endpoint.portSshCommandFormat.replace(portToken, portNumber.toString()); } dev-tunnels-0.0.25/ts/src/contracts/tunnelHeaderNames.ts000066400000000000000000000022371450757157500232550ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelHeaderNames.cs /* eslint-disable */ /** * Header names for http requests that Tunnel Service can handle */ export enum TunnelHeaderNames { /** * Additional authorization header that can be passed to tunnel web forwarding to * authenticate and authorize the client. The format of the value is the same as * Authorization header that is sent to the Tunnel service by the tunnel SDK. * Supported schemes: "tunnel" with the tunnel access JWT good for 'Connect' scope. */ XTunnelAuthorization = 'X-Tunnel-Authorization', /** * Request ID header that nginx ingress controller adds to all requests if it's not * there. */ XRequestID = 'X-Request-ID', /** * Github Ssh public key which can be used to validate if it belongs to tunnel's * owner. */ XGithubSshKey = 'X-Github-Ssh-Key', /** * Header that will skip the antiphishing page when connection to a tunnel through web * forwarding. */ XTunnelSkipAntiPhishingPage = 'X-Tunnel-Skip-AntiPhishing-Page', } dev-tunnels-0.0.25/ts/src/contracts/tunnelListByRegion.ts000066400000000000000000000011771450757157500234550ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelListByRegion.cs /* eslint-disable */ import { ErrorDetail } from './errorDetail'; import { TunnelV2 } from './tunnelV2'; /** * Tunnel list by region. */ export interface TunnelListByRegion { /** * Azure region name. */ regionName?: string; /** * Cluster id in the region. */ clusterId?: string; /** * List of tunnels. */ value?: TunnelV2[]; /** * Error detail if getting list of tunnels in the region failed. */ error?: ErrorDetail; } dev-tunnels-0.0.25/ts/src/contracts/tunnelListByRegionResponse.ts000066400000000000000000000007701450757157500251720ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelListByRegionResponse.cs /* eslint-disable */ import { TunnelListByRegion } from './tunnelListByRegion'; /** * Data contract for response of a list tunnel by region call. */ export interface TunnelListByRegionResponse { /** * List of tunnels */ value?: TunnelListByRegion[]; /** * Link to get next page of results. */ nextLink?: string; } dev-tunnels-0.0.25/ts/src/contracts/tunnelListResponse.ts000066400000000000000000000006761450757157500235400ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelListResponse.cs /* eslint-disable */ import { TunnelV2 } from './tunnelV2'; /** * Data contract for response of a list tunnel call. */ export interface TunnelListResponse { /** * List of tunnels */ value: TunnelV2[]; /** * Link to get next page of results */ nextLink?: string; } dev-tunnels-0.0.25/ts/src/contracts/tunnelOptions.ts000066400000000000000000000052471450757157500225400ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelOptions.cs /* eslint-disable */ /** * Data contract for {@link Tunnel} or {@link TunnelPort} options. */ export interface TunnelOptions { /** * Gets or sets a value indicating whether web-forwarding of this tunnel can run on * any cluster (region) without redirecting to the home cluster. This is only * applicable if the tunnel has a name and web-forwarding uses it. */ isGloballyAvailable?: boolean; /** * Gets or sets a value for `Host` header rewriting to use in web-forwarding of this * tunnel or port. By default, with this property null or empty, web-forwarding uses * "localhost" to rewrite the header. Web-fowarding will use this property instead if * it is not null or empty. Port-level option, if set, takes precedence over this * option on the tunnel level. The option is ignored if IsHostHeaderUnchanged is true. */ hostHeader?: string; /** * Gets or sets a value indicating whether `Host` header is rewritten or the header * value stays intact. By default, if false, web-forwarding rewrites the host header * with the value from HostHeader property or "localhost". If true, the host header * will be whatever the tunnel's web-forwarding host is, e.g. * tunnel-name-8080.devtunnels.ms. Port-level option, if set, takes precedence over * this option on the tunnel level. */ isHostHeaderUnchanged?: boolean; /** * Gets or sets a value for `Origin` header rewriting to use in web-forwarding of this * tunnel or port. By default, with this property null or empty, web-forwarding uses * "http(s)://localhost" to rewrite the header. Web-fowarding will use this property * instead if it is not null or empty. Port-level option, if set, takes precedence * over this option on the tunnel level. The option is ignored if * IsOriginHeaderUnchanged is true. */ originHeader?: string; /** * Gets or sets a value indicating whether `Origin` header is rewritten or the header * value stays intact. By default, if false, web-forwarding rewrites the origin header * with the value from OriginHeader property or "http(s)://localhost". If true, the * Origin header will be whatever the tunnel's web-forwarding Origin is, e.g. * https://tunnel-name-8080.devtunnels.ms. Port-level option, if set, takes precedence * over this option on the tunnel level. */ isOriginHeaderUnchanged?: boolean; /** * Gets or sets if inspection is enabled for the tunnel. */ isInspectionEnabled?: boolean; } dev-tunnels-0.0.25/ts/src/contracts/tunnelPort.ts000066400000000000000000000062621450757157500220270ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelPort.cs /* eslint-disable */ import { TunnelAccessControl } from './tunnelAccessControl'; import { TunnelOptions } from './tunnelOptions'; import { TunnelPortStatus } from './tunnelPortStatus'; /** * Data contract for tunnel port objects managed through the tunnel service REST API. */ export interface TunnelPort { /** * Gets or sets the ID of the cluster the tunnel was created in. */ clusterId?: string; /** * Gets or sets the generated ID of the tunnel, unique within the cluster. */ tunnelId?: string; /** * Gets or sets the IP port number of the tunnel port. */ portNumber: number; /** * Gets or sets the optional short name of the port. * * The name must be unique among named ports of the same tunnel. */ name?: string; /** * Gets or sets the optional description of the port. */ description?: string; /** * Gets or sets the tags of the port. */ tags?: string[]; /** * Gets or sets the protocol of the tunnel port. * * Should be one of the string constants from {@link TunnelProtocol}. */ protocol?: string; /** * Gets or sets a value indicating whether this port is a default port for the tunnel. * * A client that connects to a tunnel (by ID or name) without specifying a port number * will connect to the default port for the tunnel, if a default is configured. Or if * the tunnel has only one port then the single port is the implicit default. * * Selection of a default port for a connection also depends on matching the * connection to the port {@link TunnelPort.protocol}, so it is possible to configure * separate defaults for distinct protocols like {@link TunnelProtocol.http} and * {@link TunnelProtocol.ssh}. */ isDefault?: boolean; /** * Gets or sets a dictionary mapping from scopes to tunnel access tokens. * * Unlike the tokens in {@link Tunnel.accessTokens}, these tokens are restricted to * the individual port. */ accessTokens?: { [scope: string]: string }; /** * Gets or sets access control settings for the tunnel port. * * See {@link TunnelAccessControl} documentation for details about the access control * model. */ accessControl?: TunnelAccessControl; /** * Gets or sets options for the tunnel port. */ options?: TunnelOptions; /** * Gets or sets current connection status of the tunnel port. */ status?: TunnelPortStatus; /** * Gets or sets the username for the ssh service user is trying to forward. * * Should be provided if the {@link TunnelProtocol} is Ssh. */ sshUser?: string; /** * Gets or sets web forwarding URIs. If set, it's a list of absolute URIs where the * port can be accessed with web forwarding. */ portForwardingUris?: string[]; /** * Gets or sets inspection URI. If set, it's an absolute URIs where the port's traffic * can be inspected. */ inspectionUri?: string; } dev-tunnels-0.0.25/ts/src/contracts/tunnelPortListResponse.ts000066400000000000000000000007301450757157500243740ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelPortListResponse.cs /* eslint-disable */ import { TunnelPortV2 } from './tunnelPortV2'; /** * Data contract for response of a list tunnel ports call. */ export interface TunnelPortListResponse { /** * List of tunnels */ value: TunnelPortV2[]; /** * Link to get next page of results */ nextLink?: string; } dev-tunnels-0.0.25/ts/src/contracts/tunnelPortStatus.ts000066400000000000000000000035431450757157500232320ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelPortStatus.cs /* eslint-disable */ import { RateStatus } from './rateStatus'; import { ResourceStatus } from './resourceStatus'; /** * Data contract for {@link TunnelPort} status. */ export interface TunnelPortStatus { /** * Gets or sets the current value and limit for the number of clients connected to the * port. * * This client connection count does not include non-port-specific connections such as * SDK and SSH clients. See {@link TunnelStatus.clientConnectionCount} for status of * those connections. This count also does not include HTTP client connections, * unless they are upgraded to websockets. HTTP connections are counted per-request * rather than per-connection: see {@link TunnelPortStatus.httpRequestRate}. */ clientConnectionCount?: number | ResourceStatus; /** * Gets or sets the UTC date time when a client was last connected to the port, or * null if a client has never connected. */ lastClientConnectionTime?: Date; /** * Gets or sets the current value and limit for the rate of client connections to the * tunnel port. * * This client connection rate does not count non-port-specific connections such as * SDK and SSH clients. See {@link TunnelStatus.clientConnectionRate} for those * connection types. This also does not include HTTP connections, unless they are * upgraded to websockets. HTTP connections are counted per-request rather than * per-connection: see {@link TunnelPortStatus.httpRequestRate}. */ clientConnectionRate?: RateStatus; /** * Gets or sets the current value and limit for the rate of HTTP requests to the * tunnel port. */ httpRequestRate?: RateStatus; } dev-tunnels-0.0.25/ts/src/contracts/tunnelPortV2.ts000066400000000000000000000062721450757157500222400ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelPortV2.cs /* eslint-disable */ import { TunnelAccessControl } from './tunnelAccessControl'; import { TunnelOptions } from './tunnelOptions'; import { TunnelPortStatus } from './tunnelPortStatus'; /** * Data contract for tunnel port objects managed through the tunnel service REST API. */ export interface TunnelPortV2 { /** * Gets or sets the ID of the cluster the tunnel was created in. */ clusterId?: string; /** * Gets or sets the generated ID of the tunnel, unique within the cluster. */ tunnelId?: string; /** * Gets or sets the IP port number of the tunnel port. */ portNumber: number; /** * Gets or sets the optional short name of the port. * * The name must be unique among named ports of the same tunnel. */ name?: string; /** * Gets or sets the optional description of the port. */ description?: string; /** * Gets or sets the tags of the port. */ labels?: string[]; /** * Gets or sets the protocol of the tunnel port. * * Should be one of the string constants from {@link TunnelProtocol}. */ protocol?: string; /** * Gets or sets a value indicating whether this port is a default port for the tunnel. * * A client that connects to a tunnel (by ID or name) without specifying a port number * will connect to the default port for the tunnel, if a default is configured. Or if * the tunnel has only one port then the single port is the implicit default. * * Selection of a default port for a connection also depends on matching the * connection to the port {@link TunnelPortV2.protocol}, so it is possible to * configure separate defaults for distinct protocols like {@link TunnelProtocol.http} * and {@link TunnelProtocol.ssh}. */ isDefault?: boolean; /** * Gets or sets a dictionary mapping from scopes to tunnel access tokens. * * Unlike the tokens in {@link Tunnel.accessTokens}, these tokens are restricted to * the individual port. */ accessTokens?: { [scope: string]: string }; /** * Gets or sets access control settings for the tunnel port. * * See {@link TunnelAccessControl} documentation for details about the access control * model. */ accessControl?: TunnelAccessControl; /** * Gets or sets options for the tunnel port. */ options?: TunnelOptions; /** * Gets or sets current connection status of the tunnel port. */ status?: TunnelPortStatus; /** * Gets or sets the username for the ssh service user is trying to forward. * * Should be provided if the {@link TunnelProtocol} is Ssh. */ sshUser?: string; /** * Gets or sets web forwarding URIs. If set, it's a list of absolute URIs where the * port can be accessed with web forwarding. */ portForwardingUris?: string[]; /** * Gets or sets inspection URI. If set, it's an absolute URIs where the port's traffic * can be inspected. */ inspectionUri?: string; } dev-tunnels-0.0.25/ts/src/contracts/tunnelProtocol.ts000066400000000000000000000013571450757157500227040ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelProtocol.cs /* eslint-disable */ /** * Defines possible values for the protocol of a {@link TunnelPort}. */ export enum TunnelProtocol { /** * The protocol is automatically detected. (TODO: Define detection semantics.) */ Auto = 'auto', /** * Unknown TCP protocol. */ Tcp = 'tcp', /** * Unknown UDP protocol. */ Udp = 'udp', /** * SSH protocol. */ Ssh = 'ssh', /** * Remote desktop protocol. */ Rdp = 'rdp', /** * HTTP protocol. */ Http = 'http', /** * HTTPS protocol. */ Https = 'https', } dev-tunnels-0.0.25/ts/src/contracts/tunnelRelayTunnelEndpoint.ts000066400000000000000000000010461450757157500250410ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelRelayTunnelEndpoint.cs /* eslint-disable */ import { TunnelEndpoint } from './tunnelEndpoint'; /** * Parameters for connecting to a tunnel via the tunnel service's built-in relay function. */ export interface TunnelRelayTunnelEndpoint extends TunnelEndpoint { /** * Gets or sets the host URI. */ hostRelayUri?: string; /** * Gets or sets the client URI. */ clientRelayUri?: string; } dev-tunnels-0.0.25/ts/src/contracts/tunnelServiceProperties.ts000066400000000000000000000071041450757157500245540ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelServiceProperties.cs /* eslint-disable */ /** * Provides environment-dependent properties about the service. */ export interface TunnelServiceProperties { /** * Gets the base URI of the service. */ serviceUri: string; /** * Gets the public AAD AppId for the service. * * Clients specify this AppId as the audience property when authenticating to the * service. */ serviceAppId: string; /** * Gets the internal AAD AppId for the service. * * Other internal services specify this AppId as the audience property when * authenticating to the tunnel service. Production services must be in the AME tenant * to use this appid. */ serviceInternalAppId: string; /** * Gets the client ID for the service's GitHub app. * * Clients apps that authenticate tunnel users with GitHub specify this as the client * ID when requesting a user token. */ gitHubAppClientId: string; } /** * Global DNS name of the production tunnel service. */ export const prodDnsName = 'global.rel.tunnels.api.visualstudio.com'; /** * Global DNS name of the pre-production tunnel service. */ export const ppeDnsName = 'global.rel.tunnels.ppe.api.visualstudio.com'; /** * Global DNS name of the development tunnel service. */ export const devDnsName = 'global.ci.tunnels.dev.api.visualstudio.com'; /** * First-party app ID: `Visual Studio Tunnel Service` * * Used for authenticating AAD/MSA users, and service principals outside the AME tenant, * in the PROD service environment. */ export const prodFirstPartyAppId = '46da2f7e-b5ef-422a-88d4-2a7f9de6a0b2'; /** * First-party app ID: `Visual Studio Tunnel Service - Test` * * Used for authenticating AAD/MSA users, and service principals outside the AME tenant, * in the PPE and DEV service environments. */ export const nonProdFirstPartyAppId = '54c45752-bacd-424a-b928-652f3eca2b18'; /** * Third-party app ID: `tunnels-prod-app-sp` * * Used for authenticating internal AAD service principals in the AME tenant, in the PROD * service environment. */ export const prodThirdPartyAppId = 'ce65d243-a913-4cae-a7dd-cb52e9f77647'; /** * Third-party app ID: `tunnels-ppe-app-sp` * * Used for authenticating internal AAD service principals in the AME tenant, in the PPE * service environment. */ export const ppeThirdPartyAppId = '544167a6-f431-4518-aac6-2fd50071928e'; /** * Third-party app ID: `tunnels-dev-app-sp` * * Used for authenticating internal AAD service principals in the corp tenant (not AME!), * in the DEV service environment. */ export const devThirdPartyAppId = 'a118c979-0249-44bb-8f95-eb0457127aeb'; /** * GitHub App Client ID for 'Visual Studio Tunnel Service' * * Used by client apps that authenticate tunnel users with GitHub, in the PROD service * environment. */ export const prodGitHubAppClientId = 'Iv1.e7b89e013f801f03'; /** * GitHub App Client ID for 'Visual Studio Tunnel Service - Test' * * Used by client apps that authenticate tunnel users with GitHub, in the PPE and DEV * service environments. */ export const nonProdGitHubAppClientId = 'Iv1.b231c327f1eaa229'; // Import static members from a non-generated file, // and re-export them as an object with the same name as the interface. import { production, staging, development, environment, } from './tunnelServicePropertiesStatics'; export const TunnelServiceProperties = { production, staging, development, environment, }; dev-tunnels-0.0.25/ts/src/contracts/tunnelServicePropertiesStatics.ts000066400000000000000000000036361450757157500261150ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { TunnelServiceProperties as ITunnelServiceProperties, prodFirstPartyAppId, nonProdFirstPartyAppId, prodThirdPartyAppId, ppeThirdPartyAppId, devThirdPartyAppId, prodGitHubAppClientId, nonProdGitHubAppClientId, prodDnsName, ppeDnsName, devDnsName, } from './tunnelServiceProperties'; /** * Gets production service properties. */ export const production = { serviceUri: `https://${prodDnsName}/`, serviceAppId: prodFirstPartyAppId, serviceInternalAppId: prodThirdPartyAppId, gitHubAppClientId: prodGitHubAppClientId, }; /** * Gets properties for the service in the staging environment (PPE). */ export const staging = { serviceUri: `https://${ppeDnsName}/`, serviceAppId: nonProdFirstPartyAppId, serviceInternalAppId: ppeThirdPartyAppId, gitHubAppClientId: nonProdGitHubAppClientId, }; /** * Gets properties for the service in the development environment. */ export const development = { serviceUri: `https://${devDnsName}/`, serviceAppId: nonProdFirstPartyAppId, serviceInternalAppId: devThirdPartyAppId, gitHubAppClientId: nonProdGitHubAppClientId, }; /** * Gets properties for the service in the specified environment. */ export function environment(environmentName: string): ITunnelServiceProperties { if (!environmentName) { throw new Error(`Invalid argument: ${environmentName}`); } switch (environmentName.toLowerCase()) { case 'prod': case 'production': return production; case 'ppe': case 'preprod': return staging; case 'dev': case 'development': return development; default: throw new Error(`Invalid service environment: ${environmentName}`); } } dev-tunnels-0.0.25/ts/src/contracts/tunnelStatus.ts000066400000000000000000000107141450757157500223630ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelStatus.cs /* eslint-disable */ import { RateStatus } from './rateStatus'; import { ResourceStatus } from './resourceStatus'; /** * Data contract for {@link Tunnel} status. */ export interface TunnelStatus { /** * Gets or sets the current value and limit for the number of ports on the tunnel. */ portCount?: number | ResourceStatus; /** * Gets or sets the current value and limit for the number of hosts currently * accepting connections to the tunnel. * * This is typically 0 or 1, but may be more than 1 if the tunnel options allow * multiple hosts. */ hostConnectionCount?: number | ResourceStatus; /** * Gets or sets the UTC time when a host was last accepting connections to the tunnel, * or null if a host has never connected. */ lastHostConnectionTime?: Date; /** * Gets or sets the current value and limit for the number of clients connected to the * tunnel. * * This counts non-port-specific client connections, which is SDK and SSH clients. See * {@link TunnelPortStatus} for status of per-port client connections. */ clientConnectionCount?: number | ResourceStatus; /** * Gets or sets the UTC time when a client last connected to the tunnel, or null if a * client has never connected. * * This reports times for non-port-specific client connections, which is SDK client * and SSH clients. See {@link TunnelPortStatus} for per-port client connections. */ lastClientConnectionTime?: Date; /** * Gets or sets the current value and limit for the rate of client connections to the * tunnel. * * This counts non-port-specific client connections, which is SDK client and SSH * clients. See {@link TunnelPortStatus} for status of per-port client connections. */ clientConnectionRate?: RateStatus; /** * Gets or sets the current value and limit for the rate of bytes being received by * the tunnel host and uploaded by tunnel clients. * * All types of tunnel and port connections, from potentially multiple clients, can * contribute to this rate. The reported rate may differ slightly from the rate * measurable by applications, due to protocol overhead. Data rate status reporting is * delayed by a few seconds, so this value is a snapshot of the data transfer rate * from a few seconds earlier. */ uploadRate?: RateStatus; /** * Gets or sets the current value and limit for the rate of bytes being sent by the * tunnel host and downloaded by tunnel clients. * * All types of tunnel and port connections, from potentially multiple clients, can * contribute to this rate. The reported rate may differ slightly from the rate * measurable by applications, due to protocol overhead. Data rate status reporting is * delayed by a few seconds, so this value is a snapshot of the data transfer rate * from a few seconds earlier. */ downloadRate?: RateStatus; /** * Gets or sets the total number of bytes received by the tunnel host and uploaded by * tunnel clients, over the lifetime of the tunnel. * * All types of tunnel and port connections, from potentially multiple clients, can * contribute to this total. The reported value may differ slightly from the value * measurable by applications, due to protocol overhead. Data transfer status * reporting is delayed by a few seconds. */ uploadTotal?: number; /** * Gets or sets the total number of bytes sent by the tunnel host and downloaded by * tunnel clients, over the lifetime of the tunnel. * * All types of tunnel and port connections, from potentially multiple clients, can * contribute to this total. The reported value may differ slightly from the value * measurable by applications, due to protocol overhead. Data transfer status * reporting is delayed by a few seconds. */ downloadTotal?: number; /** * Gets or sets the current value and limit for the rate of management API read * operations for the tunnel or tunnel ports. */ apiReadRate?: RateStatus; /** * Gets or sets the current value and limit for the rate of management API update * operations for the tunnel or tunnel ports. */ apiUpdateRate?: RateStatus; } dev-tunnels-0.0.25/ts/src/contracts/tunnelV2.ts000066400000000000000000000055261450757157500213740ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Generated from ../../../cs/src/Contracts/TunnelV2.cs /* eslint-disable */ import { TunnelAccessControl } from './tunnelAccessControl'; import { TunnelEndpoint } from './tunnelEndpoint'; import { TunnelOptions } from './tunnelOptions'; import { TunnelPortV2 } from './tunnelPortV2'; import { TunnelStatus } from './tunnelStatus'; /** * Data contract for tunnel objects managed through the tunnel service REST API. */ export interface TunnelV2 { /** * Gets or sets the ID of the cluster the tunnel was created in. */ clusterId?: string; /** * Gets or sets the generated ID of the tunnel, unique within the cluster. */ tunnelId?: string; /** * Gets or sets the optional short name (alias) of the tunnel. * * The name must be globally unique within the parent domain, and must be a valid * subdomain. */ name?: string; /** * Gets or sets the description of the tunnel. */ description?: string; /** * Gets or sets the tags of the tunnel. */ labels?: string[]; /** * Gets or sets the optional parent domain of the tunnel, if it is not using the * default parent domain. */ domain?: string; /** * Gets or sets a dictionary mapping from scopes to tunnel access tokens. */ accessTokens?: { [scope: string]: string }; /** * Gets or sets access control settings for the tunnel. * * See {@link TunnelAccessControl} documentation for details about the access control * model. */ accessControl?: TunnelAccessControl; /** * Gets or sets default options for the tunnel. */ options?: TunnelOptions; /** * Gets or sets current connection status of the tunnel. */ status?: TunnelStatus; /** * Gets or sets an array of endpoints where hosts are currently accepting client * connections to the tunnel. */ endpoints?: TunnelEndpoint[]; /** * Gets or sets a list of ports in the tunnel. * * This optional property enables getting info about all ports in a tunnel at the same * time as getting tunnel info, or creating one or more ports at the same time as * creating a tunnel. It is omitted when listing (multiple) tunnels, or when updating * tunnel properties. (For the latter, use APIs to create/update/delete individual * ports instead.) */ ports?: TunnelPortV2[]; /** * Gets or sets the time in UTC of tunnel creation. */ created?: Date; /** * Gets or the time the tunnel will be deleted if it is not used or updated. */ expiration?: Date; /** * Gets or the custom amount of time the tunnel will be valid if it is not used or * updated in seconds. */ customExpiration?: number; } dev-tunnels-0.0.25/ts/src/management/000077500000000000000000000000001450757157500174135ustar00rootroot00000000000000dev-tunnels-0.0.25/ts/src/management/LICENSE000066400000000000000000000021651450757157500204240ustar00rootroot00000000000000 MIT License Copyright (c) Microsoft Corporation. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE dev-tunnels-0.0.25/ts/src/management/README.md000066400000000000000000000001161450757157500206700ustar00rootroot00000000000000# Visual Studio Tunnels Contracts Library Tunnels management library for node dev-tunnels-0.0.25/ts/src/management/index.ts000066400000000000000000000003731450757157500210750ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. export * from './tunnelManagementHttpClient'; export * from './tunnelManagementClient'; export * from './tunnelRequestOptions'; export * from './tunnelAccessTokenProperties'; dev-tunnels-0.0.25/ts/src/management/package.json000066400000000000000000000011071450757157500217000ustar00rootroot00000000000000{ "name": "@microsoft/dev-tunnels-management", "version": "", "description": "Tunnels library for Visual Studio tools", "keywords": [ "Tunnels" ], "author": "Microsoft", "license": "MIT", "scripts": { "compile": "npm run -C ../.. compile", "eslint": "npm run -C ../.. eslint", "eslint-fix": "npm run -C ../.. eslint-fix", "watch": "npm run -C ../.. watch .", "test": "npm run -C ../.. test" }, "dependencies": { "buffer": "^5.2.1", "debug": "^4.1.1", "vscode-jsonrpc": "^4.0.0", "@microsoft/dev-tunnels-contracts": "^1.0.0", "axios": "^0.21.1" } } dev-tunnels-0.0.25/ts/src/management/tsconfig.json000066400000000000000000000004421450757157500221220ustar00rootroot00000000000000{ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "../../out/lib/management", "tsBuildInfoFile": "../../out/lib/management/tsbuildinfo.json", "rootDir": "." }, "include": [ "**/*.ts", "package.json" ], "references": [ { "path": "../contracts" } ] } dev-tunnels-0.0.25/ts/src/management/tunnelAccessTokenProperties.ts000066400000000000000000000133571450757157500255010ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { Tunnel } from '@microsoft/dev-tunnels-contracts'; /** * Supports parsing tunnel access token JWT properties to allow for some pre-validation * and diagnostics. * * Applications generally should not attempt to interpret or rely on any token properties * other than `expiration`, because the service may change or omit those claims in the future. * Other claims are exposed here only for diagnostic purposes. */ export class TunnelAccessTokenProperties { private constructor( public readonly clusterId?: string, public readonly tunnelId?: string, public readonly tunnelPorts?: number[], public readonly scopes?: string[], public readonly issuer?: string, public readonly expiration?: Date, ) {} public toString(): string { let s = ''; if (this.tunnelId) { s += 'tunnel='; s += this.tunnelId; if (this.clusterId) { s += '.'; s += this.clusterId; } } if (this.tunnelPorts && this.tunnelPorts.length > 0) { if (s.length > 0) s += ', '; if (this.tunnelPorts.length === 1) { s += `port=${this.tunnelPorts[0]}`; } else { s += `ports=[${this.tunnelPorts.join(', ')}]`; } } if (this.scopes) { if (s.length > 0) s += ', '; s += `scopes=[${this.scopes.join(', ')}]`; } if (this.issuer) { if (s.length > 0) s += ', '; s += 'issuer='; s += this.issuer; } if (this.expiration) { if (s.length > 0) s += ', '; s += `expiration=${this.expiration.toString().replace('.000Z', 'Z')}`; } return s; } /** * Checks if the tunnel access token expiration claim is in the past. * * (Does not throw if the token is an invalid format.) */ public static validateTokenExpiration(token: string): void { const t = TunnelAccessTokenProperties.tryParse(token); if (t?.expiration) { if (t.expiration < new Date()) { throw new Error('The access token is expired: ' + t); } } } /** * Attempts to parse a tunnel access token (JWT). This does NOT validate the token * signature or any claims. */ public static tryParse(token: string): TunnelAccessTokenProperties | null { if (typeof token !== 'string') throw new TypeError('Token string expected.'); // JWTs are encoded in 3 parts: header, body, and signature. const tokenParts = token.split('.'); if (tokenParts.length !== 3) { return null; } const tokenBodyJson = TunnelAccessTokenProperties.base64UrlDecode(tokenParts[1]); if (!tokenBodyJson) { return null; } try { const tokenJson = JSON.parse(tokenBodyJson); const clusterId: string | undefined = tokenJson.clusterId; const tunnelId: string | undefined = tokenJson.tunnelId; const ports: number | number[] | undefined = tokenJson.tunnelPorts; const scp: string | undefined = tokenJson.scp; const iss: string | undefined = tokenJson.iss; const exp: number | undefined = tokenJson.exp; return new TunnelAccessTokenProperties( clusterId, tunnelId, typeof ports === 'number' ? [ports] : ports, scp?.split(' '), iss, typeof exp === 'number' ? new Date(exp * 1000) : undefined, ); } catch { return null; } } /** * Gets the tunnal access token trace string. * 'none' if null or undefined, parsed token info if can be parsed, or 'token' if cannot be parsed. */ public static getTokenTrace(token?: string | null | undefined): string { return !token ? 'none' : TunnelAccessTokenProperties.tryParse(token)?.toString() ?? 'token'; } /** * Gets a tunnel access token that matches any of the provided access token scopes. * Validates token expiration if the token is found and throws an error if it's expired. * @param tunnel The tunnel to get the access tokens from. * @param accessTokenScopes What scopes the token needs to have. * @returns Tunnel access token if found; otherwise, undefined. */ public static getTunnelAccessToken( tunnel?: Tunnel | null, accessTokenScopes?: string | string[], ): string | undefined { if (!tunnel?.accessTokens || !accessTokenScopes) { return; } if (!Array.isArray(accessTokenScopes)) { accessTokenScopes = [accessTokenScopes]; } for (const scope of accessTokenScopes) { for (const [key, accessToken] of Object.entries(tunnel.accessTokens)) { // Each key may be either a single scope or space-delimited list of scopes. if (accessToken && key.split(' ').includes(scope)) { TunnelAccessTokenProperties.validateTokenExpiration(accessToken); return accessToken; } } } } private static base64UrlDecode(encodedString: string): string | null { // Convert from base64url encoding to base64 encoding: replace chars and add padding. encodedString = encodedString.replace('-', '+'); while (encodedString.length % 4 !== 0) { encodedString += '='; } try { const result = atob(encodedString); return result; } catch { return null; } } } dev-tunnels-0.0.25/ts/src/management/tunnelManagementClient.ts000066400000000000000000000124031450757157500244240ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { ClusterDetails, NamedRateStatus, Tunnel, TunnelConnectionMode, TunnelEndpoint, TunnelPort, } from '@microsoft/dev-tunnels-contracts'; import { TunnelRequestOptions } from './tunnelRequestOptions'; import * as https from 'https'; /** * Interface for a client that manages tunnels and tunnel ports * via the tunnel service management API. */ export interface TunnelManagementClient { /** * Override https agent for axios requests. */ httpsAgent?: https.Agent; /** * Lists tunnels that are owned by the caller. * * The list can be filtered by setting `TunnelRequestOptions.tags`. Ports will not be * included in the returned tunnels unless `TunnelRequestOptions.includePorts` is set to true. * * @param clusterId A tunnel cluster ID, or null to list tunnels globally. * @param domain Tunnel domain, or null for the default domain. * @param options Request options. */ listTunnels( clusterId?: string, domain?: string, options?: TunnelRequestOptions, ): Promise; /** * Gets one tunnel by ID or name. * * Ports will not be included in the returned tunnel unless `TunnelRequestOptions.includePorts` * is set to true. * * @param tunnel Tunnel object including at least either a tunnel name (globally unique, * if configured) or tunnel ID and cluster ID. * @param options Request options. */ getTunnel(tunnel: Tunnel, options?: TunnelRequestOptions): Promise; /** * Creates a tunnel. * @param tunnel * @param options */ createTunnel(tunnel: Tunnel, options?: TunnelRequestOptions): Promise; /** * Updates properties of a tunnel. * @param tunnel * @param options */ updateTunnel(tunnel: Tunnel, options?: TunnelRequestOptions): Promise; /** * Deletes a tunnel. * @param tunnel * @param options */ deleteTunnel(tunnel: Tunnel, options?: TunnelRequestOptions): Promise; /** * Creates or updates an endpoint for the tunnel. * @param tunnel * @param endpoint * @param options */ updateTunnelEndpoint( tunnel: Tunnel, endpoint: TunnelEndpoint, options?: TunnelRequestOptions, ): Promise; /** * Deletes a tunnel endpoint. * @param tunnel * @param hostId * @param connectionMode * @param options */ deleteTunnelEndpoints( tunnel: Tunnel, hostId: string, connectionMode?: TunnelConnectionMode, options?: TunnelRequestOptions, ): Promise; /** * Lists ports on a tunnel. * * The list can be filtered by setting `TunnelRequestOptions.tags`. * * @param tunnel Tunnel object including at least either a tunnel name (globally unique, * if configured) or tunnel ID and cluster ID. * @param options Request options. */ listTunnelPorts(tunnel: Tunnel, options?: TunnelRequestOptions): Promise; /** * Gets one port on a tunnel by port number. * @param tunnel * @param portNumber * @param options */ getTunnelPort( tunnel: Tunnel, portNumber: number, options?: TunnelRequestOptions, ): Promise; /** * Creates a tunnel port. * @param tunnel * @param tunnelPort * @param options */ createTunnelPort( tunnel: Tunnel, tunnelPort: TunnelPort, options?: TunnelRequestOptions, ): Promise; /** * Updates properties of a tunnel port. * @param tunnel * @param tunnelPort * @param options */ updateTunnelPort( tunnel: Tunnel, tunnelPort: TunnelPort, options?: TunnelRequestOptions, ): Promise; /** * Deletes a tunnel port. * @param tunnel * @param portNumber * @param options */ deleteTunnelPort( tunnel: Tunnel, portNumber: number, options?: TunnelRequestOptions, ): Promise; /** * Lists limits and consumption status for the calling user. */ listUserLimits(): Promise; /** * Lists details of tunneling service clusters in all supported Azure regions. */ listClusters(): Promise; /** * Checks if the tunnel name is available. * @param tunnelName */ checkNameAvailablility(tunnelName: string): Promise; } /** * Interface for the user agent product information for TunnelManagementClient */ export interface ProductHeaderValue { /** * Product name. */ name: string; /** * Product version. If not supplied, 'unknown' version is used. */ version?: string; } export abstract class TunnelAuthenticationSchemes { /** Authentication scheme for AAD (or Microsoft account) access tokens. */ public static readonly aad = 'aad'; /** Authentication scheme for GitHub access tokens. */ public static readonly github = 'github'; /** Authentication scheme for tunnel access tokens. */ public static readonly tunnel = 'tunnel'; } dev-tunnels-0.0.25/ts/src/management/tunnelManagementHttpClient.ts000066400000000000000000001014361450757157500252710ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { Tunnel, TunnelConnectionMode, TunnelAccessControl, TunnelAccessScopes, TunnelEndpoint, TunnelPort, ProblemDetails, TunnelServiceProperties, ClusterDetails, NamedRateStatus, } from '@microsoft/dev-tunnels-contracts'; import { ProductHeaderValue, TunnelAuthenticationSchemes, TunnelManagementClient, } from './tunnelManagementClient'; import { TunnelRequestOptions } from './tunnelRequestOptions'; import { TunnelAccessTokenProperties } from './tunnelAccessTokenProperties'; import { tunnelSdkUserAgent } from './version'; import axios, { AxiosAdapter, AxiosError, AxiosRequestConfig, AxiosResponse, Method } from 'axios'; import * as https from 'https'; import { TunnelPlanTokenProperties } from './tunnelPlanTokenProperties'; type NullableIfNotBoolean = T extends boolean ? T : T | null; const apiV1Path = `/api/v1`; const tunnelsApiPath = apiV1Path + '/tunnels'; const limitsApiPath = apiV1Path + '/userlimits'; const endpointsApiSubPath = '/endpoints'; const portsApiSubPath = '/ports'; const clustersApiPath = apiV1Path + '/clusters'; const tunnelAuthentication = 'Authorization'; const checkAvailablePath = '/checkAvailability'; function comparePorts(a: TunnelPort, b: TunnelPort) { return (a.portNumber ?? Number.MAX_SAFE_INTEGER) - (b.portNumber ?? Number.MAX_SAFE_INTEGER); } function parseDate(value?: string | Date) { return typeof value === 'string' ? new Date(Date.parse(value)) : value; } /** * Fixes Tunnel properties of type Date that were deserialized as strings. */ function parseTunnelDates(tunnel: Tunnel | null) { if (!tunnel) return; tunnel.created = parseDate(tunnel.created); if (tunnel.status) { tunnel.status.lastHostConnectionTime = parseDate(tunnel.status.lastHostConnectionTime); tunnel.status.lastClientConnectionTime = parseDate(tunnel.status.lastClientConnectionTime); } } /** * Fixes TunnelPort properties of type Date that were deserialized as strings. */ function parseTunnelPortDates(port: TunnelPort | null) { if (!port) return; if (port.status) { port.status.lastClientConnectionTime = parseDate(port.status.lastClientConnectionTime); } } /** * Copy access tokens from the request object to the result object, except for any * tokens that were refreshed by the request. */ function preserveAccessTokens( requestObject: T, resultObject: T | null, ) { // This intentionally does not check whether any existing tokens are expired. So // expired tokens may be preserved also, if not refreshed. This allows for better // diagnostics in that case. if (requestObject.accessTokens && resultObject) { resultObject.accessTokens ??= {}; for (const scopeAndToken of Object.entries(requestObject.accessTokens)) { if (!resultObject.accessTokens[scopeAndToken[0]]) { resultObject.accessTokens[scopeAndToken[0]] = scopeAndToken[1]; } } } } const manageAccessTokenScope = [TunnelAccessScopes.Manage]; const hostAccessTokenScope = [TunnelAccessScopes.Host]; const managePortsAccessTokenScopes = [ TunnelAccessScopes.Manage, TunnelAccessScopes.ManagePorts, TunnelAccessScopes.Host, ]; const readAccessTokenScopes = [ TunnelAccessScopes.Manage, TunnelAccessScopes.ManagePorts, TunnelAccessScopes.Host, TunnelAccessScopes.Connect, ]; export class TunnelManagementHttpClient implements TunnelManagementClient { public additionalRequestHeaders?: { [header: string]: string }; private readonly baseAddress: string; private readonly userTokenCallback: () => Promise; private readonly userAgents: string; public trace: (msg: string) => void = (msg) => {}; /** * Initializes a new instance of the `TunnelManagementHttpClient` class * with a client authentication callback, service URI, and HTTP handler. * * @param userAgent { name, version } object or a comment string to use as the User-Agent header. * @param userTokenCallback Optional async callback for retrieving a client authentication * header value with access token, for AAD or GitHub user authentication. This may be omitted * for anonymous tunnel clients, or if tunnel access tokens will be specified via * `TunnelRequestOptions.accessToken`. * @param tunnelServiceUri Optional tunnel service URI (not including any path). Defaults to * the global tunnel service URI. * @param httpsAgent Optional agent that will be invoked for HTTPS requests to the tunnel * service. * @param adapter Optional axios adapter to use for HTTP requests. */ public constructor( userAgents: (ProductHeaderValue | string)[] | ProductHeaderValue | string, userTokenCallback?: () => Promise, tunnelServiceUri?: string, public readonly httpsAgent?: https.Agent, private readonly adapter?: AxiosAdapter ) { if (!userAgents) { throw new TypeError('User agent must be provided.'); } if (Array.isArray(userAgents)) { if (userAgents.length === 0) { throw new TypeError('User agents cannot be empty.'); } let combinedUserAgents = ''; userAgents.forEach((userAgent) => { if (typeof userAgent !== 'string') { if (!userAgent.name) { throw new TypeError('Invalid user agent. The name must be provided.'); } if (typeof userAgent.name !== 'string') { throw new TypeError('Invalid user agent. The name must be a string.'); } if (userAgent.version && typeof userAgent.version !== 'string') { throw new TypeError('Invalid user agent. The version must be a string.'); } combinedUserAgents = `${combinedUserAgents}${ userAgent.name }/${userAgent.version ?? 'unknown'} `; } else { combinedUserAgents = `${combinedUserAgents}${userAgent} `; } }); this.userAgents = combinedUserAgents.trim(); } else if (typeof userAgents !== 'string') { if (!userAgents.name) { throw new TypeError('Invalid user agent. The name must be provided.'); } if (typeof userAgents.name !== 'string') { throw new TypeError('Invalid user agent. The name must be a string.'); } if (userAgents.version && typeof userAgents.version !== 'string') { throw new TypeError('Invalid user agent. The version must be a string.'); } this.userAgents = `${userAgents.name}/${userAgents.version ?? 'unknown'}`; } else { this.userAgents = userAgents; } this.userTokenCallback = userTokenCallback ?? (() => Promise.resolve(null)); if (!tunnelServiceUri) { tunnelServiceUri = TunnelServiceProperties.production.serviceUri; } const parsedUri = new URL(tunnelServiceUri); if (!parsedUri || parsedUri.pathname !== '/') { throw new TypeError(`Invalid tunnel service URI: ${tunnelServiceUri}`); } this.baseAddress = tunnelServiceUri; } public async listTunnels( clusterId?: string, domain?: string, options?: TunnelRequestOptions, ): Promise { const queryParams = [clusterId ? null : 'global=true', domain ? `domain=${domain}` : null]; const query = queryParams.filter((p) => !!p).join('&'); const results = (await this.sendRequest( 'GET', clusterId, tunnelsApiPath, query, options, ))!; results.forEach(parseTunnelDates); return results; } public async getTunnel(tunnel: Tunnel, options?: TunnelRequestOptions): Promise { const result = await this.sendTunnelRequest( 'GET', tunnel, readAccessTokenScopes, undefined, undefined, options, ); preserveAccessTokens(tunnel, result); parseTunnelDates(result); return result; } public async createTunnel(tunnel: Tunnel, options?: TunnelRequestOptions): Promise { const tunnelId = tunnel.tunnelId; if (tunnelId) { throw new Error('An ID may not be specified when creating a tunnel.'); } tunnel = this.convertTunnelForRequest(tunnel); const result = (await this.sendRequest( 'POST', tunnel.clusterId, tunnelsApiPath, undefined, options, tunnel, ))!; preserveAccessTokens(tunnel, result); parseTunnelDates(result); return result; } public async updateTunnel(tunnel: Tunnel, options?: TunnelRequestOptions): Promise { const result = (await this.sendTunnelRequest( 'PUT', tunnel, manageAccessTokenScope, undefined, undefined, options, this.convertTunnelForRequest(tunnel), ))!; preserveAccessTokens(tunnel, result); parseTunnelDates(result); return result; } public async deleteTunnel(tunnel: Tunnel, options?: TunnelRequestOptions): Promise { return await this.sendTunnelRequest( 'DELETE', tunnel, manageAccessTokenScope, undefined, undefined, options, undefined, true, ); } public async updateTunnelEndpoint( tunnel: Tunnel, endpoint: TunnelEndpoint, options?: TunnelRequestOptions, ): Promise { const path = `${endpointsApiSubPath}/${endpoint.hostId}/${endpoint.connectionMode}`; const result = (await this.sendTunnelRequest( 'PUT', tunnel, hostAccessTokenScope, path, undefined, options, endpoint, ))!; if (tunnel.endpoints) { // Also update the endpoint in the local tunnel object. tunnel.endpoints = tunnel.endpoints .filter( (e) => e.hostId !== endpoint.hostId || e.connectionMode !== endpoint.connectionMode, ) .concat(result); } return result; } public async deleteTunnelEndpoints( tunnel: Tunnel, hostId: string, connectionMode?: TunnelConnectionMode, options?: TunnelRequestOptions, ): Promise { const path = connectionMode == null ? `${endpointsApiSubPath}/${hostId}` : `${endpointsApiSubPath}/${hostId}/${connectionMode}`; const result = await this.sendTunnelRequest( 'DELETE', tunnel, hostAccessTokenScope, path, undefined, options, undefined, true, ); if (result && tunnel.endpoints) { // Also delete the endpoint in the local tunnel object. tunnel.endpoints = tunnel.endpoints.filter( (e) => e.hostId !== hostId || e.connectionMode !== connectionMode, ); } return result; } public async listUserLimits(): Promise { const results = await this.sendRequest( 'GET', undefined, limitsApiPath, undefined, undefined, ); return results || []; } public async listTunnelPorts( tunnel: Tunnel, options?: TunnelRequestOptions, ): Promise { const results = (await this.sendTunnelRequest( 'GET', tunnel, readAccessTokenScopes, portsApiSubPath, undefined, options, ))!; results.forEach(parseTunnelPortDates); return results; } public async getTunnelPort( tunnel: Tunnel, portNumber: number, options?: TunnelRequestOptions, ): Promise { const path = `${portsApiSubPath}/${portNumber}`; const result = await this.sendTunnelRequest( 'GET', tunnel, readAccessTokenScopes, path, undefined, options, ); parseTunnelPortDates(result); return result; } public async createTunnelPort( tunnel: Tunnel, tunnelPort: TunnelPort, options?: TunnelRequestOptions, ): Promise { tunnelPort = this.convertTunnelPortForRequest(tunnel, tunnelPort); const result = (await this.sendTunnelRequest( 'POST', tunnel, managePortsAccessTokenScopes, portsApiSubPath, undefined, options, tunnelPort, ))!; if (tunnel.ports) { // Also add the port to the local tunnel object. tunnel.ports = tunnel.ports .filter((p) => p.portNumber !== tunnelPort.portNumber) .concat(result) .sort(comparePorts); } parseTunnelPortDates(result); return result; } public async updateTunnelPort( tunnel: Tunnel, tunnelPort: TunnelPort, options?: TunnelRequestOptions, ): Promise { if (tunnelPort.clusterId && tunnel.clusterId && tunnelPort.clusterId !== tunnel.clusterId) { throw new Error('Tunnel port cluster ID is not consistent.'); } const portNumber = tunnelPort.portNumber; const path = `${portsApiSubPath}/${portNumber}`; tunnelPort = this.convertTunnelPortForRequest(tunnel, tunnelPort); const result = (await this.sendTunnelRequest( 'PUT', tunnel, managePortsAccessTokenScopes, path, undefined, options, tunnelPort, ))!; preserveAccessTokens(tunnelPort, result); parseTunnelPortDates(result); if (tunnel.ports) { // Also update the port in the local tunnel object. tunnel.ports = tunnel.ports .filter((p) => p.portNumber !== tunnelPort.portNumber) .concat(result) .sort(comparePorts); } return result; } public async deleteTunnelPort( tunnel: Tunnel, portNumber: number, options?: TunnelRequestOptions, ): Promise { const path = `${portsApiSubPath}/${portNumber}`; const result = await this.sendTunnelRequest( 'DELETE', tunnel, managePortsAccessTokenScopes, path, undefined, options, undefined, true, ); if (result && tunnel.ports) { // Also delete the port in the local tunnel object. tunnel.ports = tunnel.ports .filter((p) => p.portNumber !== portNumber) .sort(comparePorts); } return result; } public async listClusters(): Promise { return (await this.sendRequest( 'GET', undefined, clustersApiPath, undefined, undefined, undefined, false, ))!; } /** * Sends an HTTP request to the tunnel management API, targeting a specific tunnel. * This protected method enables subclasses to support additional tunnel management APIs. * @param method HTTP request method. * @param tunnel Tunnel that the request is targeting. * @param accessTokenScopes Required array of access scopes for tokens in `tunnel.accessTokens` * that could be used to authorize the request. * @param path Optional request sub-path relative to the tunnel. * @param query Optional query string to append to the request. * @param options Request options. * @param body Optional request body object. * @param allowNotFound If true, a 404 response is returned as a null or false result * instead of an error. * @returns Result of the request. */ protected async sendTunnelRequest( method: Method, tunnel: Tunnel, accessTokenScopes: string[], path?: string, query?: string, options?: TunnelRequestOptions, body?: object, allowNotFound?: boolean, ): Promise> { const uri = await this.buildUriForTunnel(tunnel, path, query, options); const config = await this.getAxiosRequestConfig(tunnel, options, accessTokenScopes); const result = await this.request(method, uri, body, config, allowNotFound); return result; } /** * Sends an HTTP request to the tunnel management API. * This protected method enables subclasses to support additional tunnel management APIs. * @param method HTTP request method. * @param clusterId Optional tunnel service cluster ID to direct the request to. If unspecified, * the request will use the global traffic-manager to find the nearest cluster. * @param path Required request path. * @param query Optional query string to append to the request. * @param options Request options. * @param body Optional request body object. * @param allowNotFound If true, a 404 response is returned as a null or false result * instead of an error. * @returns Result of the request. */ protected async sendRequest( method: Method, clusterId: string | undefined, path: string, query?: string, options?: TunnelRequestOptions, body?: object, allowNotFound?: boolean, ): Promise> { const uri = await this.buildUri(clusterId, path, query, options); const config = await this.getAxiosRequestConfig(undefined, options); const result = await this.request(method, uri, body, config, allowNotFound); return result; } public async checkNameAvailablility(tunnelName: string): Promise { tunnelName = encodeURI(tunnelName); const uri = await this.buildUri( undefined, `${tunnelsApiPath}/${tunnelName}${checkAvailablePath}`, ); const config: AxiosRequestConfig = { httpsAgent: this.httpsAgent, adapter: this.adapter, }; return await this.request('GET', uri, undefined, config); } private getResponseErrorMessage(error: AxiosError) { let errorMessage = ''; if (error.response?.data) { const problemDetails: ProblemDetails = error.response.data; if (problemDetails.title || problemDetails.detail) { errorMessage = `Tunnel service error: ${problemDetails.title}`; if (problemDetails.detail) { errorMessage += ' ' + problemDetails.detail; } if (problemDetails.errors) { errorMessage += JSON.stringify(problemDetails.errors); } } } if (!errorMessage) { if (error?.response) { errorMessage = 'Tunnel service returned status code: ' + `${error.response.status} ${error.response.statusText}`; } else { errorMessage = error?.message ?? error ?? 'Unknown tunnel service request error.'; } } const requestIdHeaderName = 'VsSaaS-Request-Id'; if (error.response?.headers && error.response.headers[requestIdHeaderName]) { errorMessage += `\nRequest ID: ${error.response.headers[requestIdHeaderName]}`; } return errorMessage; } // Helper functions private async buildUri( clusterId: string | undefined, path: string, query?: string, options?: TunnelRequestOptions, ) { if (clusterId === undefined && this.userTokenCallback) { let token = await this.userTokenCallback(); if (token && token.startsWith("tunnelplan")) { token = token.replace("tunnelplan ", ""); const parsedToken = TunnelPlanTokenProperties.tryParse(token) if (parsedToken !== null && parsedToken.clusterId) { clusterId = parsedToken.clusterId } } } let baseAddress = this.baseAddress; if (clusterId) { const url = new URL(baseAddress); const portNumber = parseInt(url.port, 10); if (url.hostname !== 'localhost' && !url.hostname.startsWith(`${clusterId}.`)) { // A specific cluster ID was specified (while not running on localhost). // Prepend the cluster ID to the hostname, and optionally strip a global prefix. url.hostname = `${clusterId}.${url.hostname}`.replace('global.', ''); baseAddress = url.toString(); } else if ( url.protocol === 'https:' && clusterId.startsWith('localhost') && portNumber % 10 > 0 ) { // Local testing simulates clusters by running the service on multiple ports. // Change the port number to match the cluster ID suffix. const clusterNumber = parseInt(clusterId.substring('localhost'.length), 10); if (clusterNumber > 0 && clusterNumber < 10) { url.port = (portNumber - (portNumber % 10) + clusterNumber).toString(); baseAddress = url.toString(); } } } baseAddress = `${baseAddress.replace(/\/$/, '')}${path}`; const optionsQuery = this.tunnelRequestOptionsToQueryString(options, query); if (optionsQuery) { baseAddress += `?${optionsQuery}`; } return baseAddress; } private buildUriForTunnel( tunnel: Tunnel, path?: string, query?: string, options?: TunnelRequestOptions, ) { let tunnelPath = ''; if (tunnel.clusterId && tunnel.tunnelId) { tunnelPath = `${tunnelsApiPath}/${tunnel.tunnelId}`; } else { if (!tunnel.name) { throw new Error( 'Tunnel object must include either a name or tunnel ID and cluster ID.', ); } tunnelPath = `${tunnelsApiPath}/${tunnel.name}`; } if (options?.additionalQueryParameters) { for (const [paramName, paramValue] of Object.entries(options.additionalQueryParameters)) { if (query) { query += `&${paramName}=${paramValue}`; } else { query = `${paramName}=${paramValue}`; } } } return this.buildUri(tunnel.clusterId, tunnelPath + (path ? path : ''), query, options); } private async getAxiosRequestConfig( tunnel?: Tunnel, options?: TunnelRequestOptions, accessTokenScopes?: string[], ): Promise { // Get access token header const headers: { [name: string]: string } = {}; if (options && options.accessToken) { TunnelAccessTokenProperties.validateTokenExpiration(options.accessToken); headers[ tunnelAuthentication ] = `${TunnelAuthenticationSchemes.tunnel} ${options.accessToken}`; } if (!(tunnelAuthentication in headers) && this.userTokenCallback) { const token = await this.userTokenCallback(); if (token) { headers[tunnelAuthentication] = token; } } if (!(tunnelAuthentication in headers)) { const accessToken = TunnelAccessTokenProperties.getTunnelAccessToken( tunnel, accessTokenScopes, ); if (accessToken) { headers[ tunnelAuthentication ] = `${TunnelAuthenticationSchemes.tunnel} ${accessToken}`; } } const copyAdditionalHeaders = (additionalHeaders?: { [name: string]: string }) => { if (additionalHeaders) { for (const [headerName, headerValue] of Object.entries(additionalHeaders)) { headers[headerName] = headerValue; } } }; copyAdditionalHeaders(this.additionalRequestHeaders); copyAdditionalHeaders(options?.additionalHeaders); const userAgentPrefix = headers['User-Agent'] ? headers['User-Agent'] + ' ' : ''; headers['User-Agent'] = `${userAgentPrefix}${this.userAgents} ${tunnelSdkUserAgent}`; // Get axios config const config: AxiosRequestConfig = { headers, ...(this.httpsAgent && { httpsAgent: this.httpsAgent }), ...(this.adapter && { adapter: this.adapter }), }; if (options?.followRedirects === false) { config.maxRedirects = 0; } return config; } private convertTunnelForRequest(tunnel: Tunnel): Tunnel { const convertedTunnel: Tunnel = { name: tunnel.name, domain: tunnel.domain, description: tunnel.description, tags: tunnel.tags, options: tunnel.options, customExpiration: tunnel.customExpiration, accessControl: !tunnel.accessControl ? undefined : { entries: tunnel.accessControl.entries.filter((ace) => !ace.isInherited) }, endpoints: tunnel.endpoints, ports: tunnel.ports?.map((p) => this.convertTunnelPortForRequest(tunnel, p)), }; return convertedTunnel; } private convertTunnelPortForRequest(tunnel: Tunnel, tunnelPort: TunnelPort): TunnelPort { if (tunnelPort.clusterId && tunnel.clusterId && tunnelPort.clusterId !== tunnel.clusterId) { throw new Error('Tunnel port cluster ID does not match tunnel.'); } if (tunnelPort.tunnelId && tunnel.tunnelId && tunnelPort.tunnelId !== tunnel.tunnelId) { throw new Error('Tunnel port tunnel ID does not match tunnel.'); } return { portNumber: tunnelPort.portNumber, protocol: tunnelPort.protocol, isDefault: tunnelPort.isDefault, description: tunnelPort.description, tags: tunnelPort.tags, sshUser: tunnelPort.sshUser, options: tunnelPort.options, accessControl: !tunnelPort.accessControl ? undefined : { entries: tunnelPort.accessControl.entries.filter((ace) => !ace.isInherited) }, }; } private tunnelRequestOptionsToQueryString( options?: TunnelRequestOptions, additionalQuery?: string, ) { const queryOptions: { [name: string]: string[] } = {}; const queryItems = []; if (options) { if (options.includePorts) { queryOptions.includePorts = ['true']; } if (options.includeAccessControl) { queryOptions.includeAccessControl = ['true']; } if (options.tokenScopes) { TunnelAccessControl.validateScopes(options.tokenScopes, undefined, true); queryOptions.tokenScopes = options.tokenScopes; } if (options.forceRename) { queryOptions.forceRename = ['true']; } if (options.tags) { queryOptions.tags = options.tags; if (options.requireAllTags) { queryOptions.allTags = ['true']; } } if (options.limit) { queryOptions.limit = [options.limit.toString()]; } queryItems.push( ...Object.keys(queryOptions).map((key) => { const value = queryOptions[key]; return `${key}=${value.map(encodeURIComponent).join(',')}`; }), ); } if (additionalQuery) { queryItems.push(additionalQuery); } const queryString = queryItems.join('&'); return queryString; } /** * Makes an HTTP request using Axios, while tracing request and response details. */ private async request( method: Method, uri: string, data: any, config: AxiosRequestConfig, allowNotFound?: boolean, ): Promise> { this.trace(`${method} ${uri}`); this.traceHeaders(config.headers); this.traceContent(data); const traceResponse = (response: AxiosResponse) => { this.trace(`${response.status} ${response.statusText}`); this.traceHeaders(response.headers); this.traceContent(response.data); }; try { config.url = uri; config.method = method; config.data = data; const response = await axios.request(config); traceResponse(response); // This assumes that TResult is always boolean for DELETE requests. return >(method === 'DELETE' ? true : response.data); } catch (e) { if (!(e instanceof Error) || !(e as AxiosError).isAxiosError) throw e; const requestError = e as AxiosError; if (requestError.response) { traceResponse(requestError.response); if (allowNotFound && requestError.response.status === 404) { return >(method === 'DELETE' ? false : null); } } requestError.message = this.getResponseErrorMessage(requestError); // Axios errors have too much redundant detail! Delete some of it. delete requestError.request; if (requestError.response) { delete requestError.config.httpAgent; delete requestError.config.httpsAgent; delete requestError.response.request; } throw requestError; } } private traceHeaders(headers: { [key: string]: unknown }): void { for (const [headerName, headerValue] of Object.entries(headers)) { if (headerName === 'Authorization') { this.traceAuthorizationHeader(headerName, headerValue as string); return; } this.trace(`${headerName}: ${headerValue ?? ''}`); } } private traceAuthorizationHeader(key: string, value: string): void { if (typeof value !== 'string') return; const spaceIndex = value.indexOf(' '); if (spaceIndex < 0) { this.trace(`${key}: [${value.length}]`); return; } const scheme = value.substring(0, spaceIndex); const token = value.substring(spaceIndex + 1); if (scheme.toLowerCase() === TunnelAuthenticationSchemes.tunnel.toLowerCase()) { const tokenProperties = TunnelAccessTokenProperties.tryParse(token); if (tokenProperties) { this.trace(`${key}: ${scheme} <${tokenProperties}>`); return; } } this.trace(`${key}: ${scheme} `); } private traceContent(data: any) { if (typeof data === 'object') { data = JSON.stringify(data, undefined, ' '); } if (typeof data === 'string') { this.trace(TunnelManagementHttpClient.replaceTokensInContent(data)); } } private static replaceTokensInContent(content: string): string { const tokenRegex = /"(eyJ[a-zA-z0-9\-_]+\.[a-zA-z0-9\-_]+\.[a-zA-z0-9\-_]+)"/; let match = tokenRegex.exec(content); while (match) { let token = match[1]; const tokenProperties = TunnelAccessTokenProperties.tryParse(token); token = tokenProperties?.toString() ?? 'token'; content = content.substring(0, match.index + 1) + '<' + token + '>' + content.substring(match.index + match[0].length - 1); match = tokenRegex.exec(content); } return content; } }dev-tunnels-0.0.25/ts/src/management/tunnelPlanTokenProperties.ts000066400000000000000000000072251450757157500251670ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { Tunnel } from '@microsoft/dev-tunnels-contracts'; /** * Supports parsing tunnel access token JWT properties to allow for some pre-validation * and diagnostics. * * Applications generally should not attempt to interpret or rely on any token properties * other than `expiration`, because the service may change or omit those claims in the future. * Other claims are exposed here only for diagnostic purposes. */ export class TunnelPlanTokenProperties { private constructor( public readonly clusterId?: string, public readonly issuer?: string, public readonly expiration?: Date, public readonly userEmail?: string, public readonly tunnelPlanId?: string, public readonly subscriptionId?: string, public readonly scopes?: string[], ) {} /** * Checks if the tunnel access token expiration claim is in the past. * * (Does not throw if the token is an invalid format.) */ public static validateTokenExpiration(token: string): void { const t = TunnelPlanTokenProperties.tryParse(token); if (t?.expiration) { if (t.expiration < new Date()) { throw new Error('The access token is expired: ' + t); } } } /** * Attempts to parse a tunnel access token (JWT). This does NOT validate the token * signature or any claims. */ public static tryParse(token: string): TunnelPlanTokenProperties | null { if (typeof token !== 'string') throw new TypeError('Token string expected.'); // JWTs are encoded in 3 parts: header, body, and signature. const tokenParts = token.split('.'); if (tokenParts.length !== 3) { return null; } const tokenBodyJson = TunnelPlanTokenProperties.base64UrlDecode(tokenParts[1]); if (!tokenBodyJson) { return null; } try { const tokenJson = JSON.parse(tokenBodyJson); const clusterId: string | undefined = tokenJson.clusterId; const subscriptionId: string | undefined = tokenJson.subscriptionId; const tunnelPlanId: string | undefined = tokenJson.tunnelPlanId; const userEmail: string | undefined = tokenJson.userEmail; const scp: string | undefined = tokenJson.scp; const iss: string | undefined = tokenJson.iss; const exp: number | undefined = tokenJson.exp; return new TunnelPlanTokenProperties( clusterId, iss, typeof exp === 'number' ? new Date(exp * 1000) : undefined, userEmail, tunnelPlanId, subscriptionId, scp?.split(' '), ); } catch { return null; } } /** * Gets the tunnal access token trace string. * 'none' if null or undefined, parsed token info if can be parsed, or 'token' if cannot be parsed. */ public static getTokenTrace(token?: string | null | undefined): string { return !token ? 'none' : TunnelPlanTokenProperties.tryParse(token)?.toString() ?? 'token'; } private static base64UrlDecode(encodedString: string): string | null { // Convert from base64url encoding to base64 encoding: replace chars and add padding. encodedString = encodedString.replace('-', '+'); while (encodedString.length % 4 !== 0) { encodedString += '='; } try { const result = atob(encodedString); return result; } catch { return null; } } } dev-tunnels-0.0.25/ts/src/management/tunnelRequestOptions.ts000066400000000000000000000072331450757157500242220ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. /** * Options for tunnel service requests. */ export interface TunnelRequestOptions { /** * Gets or sets an access token for the request. * * Note this should not be a _user_ access token (such as AAD or GitHub); use the * callback parameter to the `TunnelManagementHttpClient` constructor to * supply user access tokens. */ accessToken?: string; /** * Gets or sets additional headers to be included in the request. */ additionalHeaders?: { [header: string]: string }; /** * Gets or sets additional query parameters to be included in the request. */ additionalQueryParameters?: { [name: string]: string }; /** * Gets or sets a value that indicates whether HTTP redirect responses will be * automatically followed. * * The default is true. If false, a redirect response will cause an error to be thrown, * with redirect target URI available at `error.response.headers.location`. * * The tunnel service often redirects requests to the "home" cluster of the requested * tunnel, when necessary to fulfill the request. */ followRedirects?: boolean; /** * Gets or sets a flag that requests tunnel ports when retrieving a tunnel object. * * Ports are excluded by default when retrieving a tunnel or when listing or searching * tunnels. This option enables including ports for all tunnels returned by a list or * search query. */ includePorts?: boolean; /** * Gets or sets a flag that requests access control details when retrieving tunnels. * * Access control details are always included when retrieving a single tunnel, * but excluded by default when listing or searching tunnels. This option enables * including access controls for all tunnels returned by a list or search query. */ includeAccessControl?: boolean; /** * Gets or sets an optional list of tags to filter the requested tunnels or ports. * * Requested tags are compared to the `Tunnel.tags` or `TunnelPort.tags` when calling * `TunnelManagementClient.listTunnels` or `TunnelManagementClient.listTunnelPorts` * respectively. By default, an item is included if ANY tag matches; set `requireAllTags` * to match ALL tags instead. */ tags?: string[]; /* * Gets or sets a flag that indicates whether listed items must match all tags * specified in `tags`. If false, an item is included if any tag matches. */ requireAllTags?: boolean; /** * Gets or sets an optional list of token scopes that are requested when retrieving a * tunnel or tunnel port object. * * Each item in the array must be a single scope from `TunnelAccessScopes` or a space- * delimited combination of multiple scopes. The service issues an access token for * each scope or combination and returns the token(s) in the `Tunnel.accessTokens` or * `TunnelPort.accessTokens` dictionary. If the caller does not have permission to get * a token for one or more scopes then a token is not returned but the overall request * does not fail. Token properties including scopes and expiration may be checked using * `TunnelAccessTokenProperties`. */ tokenScopes?: string[]; /** * If true on a create or update request then upon a name conflict, attempt to rename the * existing tunnel to null and give the name to the tunnel from the request. */ forceRename?: boolean; /** * Limits the number of tunnels returned when searching or listing tunnels. */ limit?: number; } dev-tunnels-0.0.25/ts/src/management/version.ts000066400000000000000000000004761450757157500214570ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // Get the package version import * as packageJson from './package.json'; const packageVersion = packageJson.version; /** * Tunnel SDK user agent */ export const tunnelSdkUserAgent = `Dev-Tunnels-Service-TypeScript-SDK/${packageVersion}`; dev-tunnels-0.0.25/ts/test/000077500000000000000000000000001450757157500154675ustar00rootroot00000000000000dev-tunnels-0.0.25/ts/test/tunnels-test/000077500000000000000000000000001450757157500201345ustar00rootroot00000000000000dev-tunnels-0.0.25/ts/test/tunnels-test/connectTests.ts000066400000000000000000000072201450757157500231610ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import * as assert from 'assert'; import { suite, test, slow, timeout, pending, params } from '@testdeck/mocha'; import { MockUserManager } from './mocks/mockUserManager'; import { MockTunnelManagementClient } from './mocks/mockTunnelManagementClient'; import { UserInfo } from './userInfo'; import { ForwardedPortsCollection, LocalPortForwarder } from '@microsoft/dev-tunnels-ssh-tcp'; import { Tunnel, TunnelPort, TunnelConnectionMode } from '@microsoft/dev-tunnels-contracts'; import { TunnelManagementClient } from '@microsoft/dev-tunnels-management'; import { TunnelClient, TunnelConnectionBase, TunnelHost } from '@microsoft/dev-tunnels-connections'; import { CancellationToken, SshStream } from '@microsoft/dev-tunnels-ssh'; import { TunnelConnectionOptions } from 'src/connections/tunnelConnectionOptions'; @suite @slow(3000) @timeout(10000) export class MetricsTests { @slow(10000) @timeout(20000) public static async before() {} @test public async connectTunnel() { const userManager = new MockUserManager(); userManager.currentUser = userManager.loginUser; let managementClient = new MockTunnelManagementClient(); managementClient.tunnels.push({ clusterId: 'localhost', tunnelId: 'test', name: 'name1', ports: [ { ClusterId: 'localhost', TunnelId: 'test', portNumber: 2000, } as TunnelPort, ], } as Tunnel); } } class MockConnectOptions { private readonly managementClient: TunnelManagementClient; private readonly clientFactory?: Function; constructor(managementClient: TunnelManagementClient, clientFactory?: Function) { this.managementClient = managementClient; this.clientFactory = clientFactory; } public createManagementClient(user: UserInfo): TunnelManagementClient { return this.managementClient; } public CreateHost( managementClient: TunnelManagementClient, connectionModes: TunnelConnectionMode[], ): TunnelHost { throw new Error('Not Supported Exception'); } public CreateClient( managementClient: TunnelManagementClient, connectionModes: TunnelConnectionMode[], ): TunnelClient { if (this.clientFactory) { return this.clientFactory(); } else { throw new Error('Not Supported Exception'); } } } class MockTunnelClient extends TunnelConnectionBase implements TunnelClient { forwardedPorts: ForwardedPortsCollection | undefined; public connectionModes: TunnelConnectionMode[] = []; public acceptLocalConnectionsForForwardedPorts = true; public localForwardingHostAddress = '127.0.0.1'; connect( tunnel: Tunnel, options?: TunnelConnectionOptions, cancellation?: CancellationToken, ): Promise { throw new Error('Method not implemented.'); } forwardPort(tunnelPort: TunnelPort): Promise { throw new Error('Method not implemented.'); } public onConnected?: Function; public onForwarding?: Function; connectToForwardedPort( fowardedPort: number, cancellation?: CancellationToken, ): Promise { throw new Error('Method not implemented.'); } waitForForwardedPort(forwardedPort: number, cancellation?: CancellationToken): Promise { throw new Error('Method not implemented.'); } refreshPorts(): Promise { throw new Error('Method not implemented.'); } } dev-tunnels-0.0.25/ts/test/tunnels-test/connection.ts000066400000000000000000000045321450757157500226470ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { TunnelRelayTunnelClient } from '@microsoft/dev-tunnels-connections'; import { Tunnel, TunnelConnectionMode } from '@microsoft/dev-tunnels-contracts'; import { TunnelManagementHttpClient, TunnelRequestOptions } from '@microsoft/dev-tunnels-management'; import * as yargs from 'yargs'; const userAgent = 'test-connection/1.0'; main() .then((exitCode) => process.exit(exitCode)) .catch((e) => { console.error(e); process.exit(1); }); async function main() { const argv = await yargs.argv; const port = ((argv.p || argv.port) as number) || 0; let optionsArray = ((argv.o || argv.option) as string | string[]) || []; if (!Array.isArray(optionsArray)) { optionsArray = [optionsArray]; } const options: { [name: string]: string } = {}; for (let i = optionsArray.length - 1; i >= 0; i--) { const nameAndValue = optionsArray[i].split('='); if (nameAndValue.length === 2) { options[nameAndValue[0]] = nameAndValue[1]; } } return startTunnelRelayConnection(); } async function connect(port: number, options: { [name: string]: string }) { console.log('starting host....'); const { execSync } = require('child_process'); execSync( './starthost.ps1', { shell: 'powershell.exe', stdio: 'inherit' }, (error: any, stdout: any, stderr: any) => { // output the messages console.log(error); console.log(stdout); console.log(stderr); }, ); return 0; } async function startTunnelRelayConnection() { let tunnelManagementClient = new TunnelManagementHttpClient( userAgent, () => Promise.resolve('Bearer'), 'http://localhost:9900/'); const tunnel: Tunnel = { tunnelId: '3xfp2wn8', clusterId: 'westus2' }; let tunnelRequestOptions: TunnelRequestOptions = { tokenScopes: ['connect'], accessToken: '', }; let tunnelInstance = await tunnelManagementClient.getTunnel(tunnel, tunnelRequestOptions); let tunnelRelayTunnelClient = new TunnelRelayTunnelClient(); await tunnelRelayTunnelClient.connect(tunnelInstance!); // Wait indefinitely so the connection does not close await new Promise((resolve) => {}); return 0; } dev-tunnels-0.0.25/ts/test/tunnels-test/duplexStream.ts000066400000000000000000000107321450757157500231640ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import * as http from 'http'; import { server as WebSocketServer, client as WebSocketClient, request as WebSocketRequest, connection as WebSocketConnection, w3cwebsocket as W3CWebSocket, } from 'websocket'; import { BaseStream, Stream, WebSocketStream } from '@microsoft/dev-tunnels-ssh'; /** * Creates a pair of connected stream adapters for testing purposes. */ export class DuplexStream extends BaseStream { private other!: DuplexStream; public static async createStreams(type?: string): Promise<[Stream, Stream]> { if (type === 'ws') { return await createWebSocketStreams(); } const stream1 = new DuplexStream(); const stream2 = new DuplexStream(); stream1.other = stream2; stream2.other = stream1; return [stream1, stream2]; } private constructor() { super(); } public async write(data: Buffer): Promise { if (!data) throw new TypeError('Data is required.'); this.other.onData(Buffer.from(data)); } public async close(error?: Error): Promise { if (!error) { this.dispose(); this.other.onEnd(); this.other.dispose(); } else { this.onError(error); this.dispose(); this.other.onError(error); this.other.dispose(); } } public dispose(): void { super.dispose(); if (!this.other.isDisposed) { this.other.onError(new Error('Stream disposed.')); this.other.dispose(); } } } let wsServer: WebSocketServer; const wsPort = 8080; async function createWebSocketStreams(): Promise<[Stream, Stream]> { if (!wsServer) { wsServer = await createWebSocketServer(); } const serverConnectionPromise = new Promise((resolve) => { wsServer.once('request', (request: WebSocketRequest) => { const connection = request.accept(); resolve(connection); }); }); const clientSocket = new W3CWebSocket('ws://localhost:' + wsPort); const clientConnectionPromise = new Promise((resolve, reject) => { clientSocket.onopen = () => { resolve(); }; clientSocket.onerror = (e) => { reject(new Error('Connection failed.')); }; }); await Promise.all([serverConnectionPromise, clientConnectionPromise]); const serverConnection = await serverConnectionPromise; return [new WebSocketServerStream(serverConnection), new WebSocketStream(clientSocket)]; } async function createWebSocketServer(): Promise { const httpServer = await new Promise((resolve) => { const s = http.createServer((request, response) => { response.writeHead(404); response.end(); }); s.listen(wsPort, () => { resolve(s); }); }); return new WebSocketServer({ httpServer, autoAcceptConnections: false, }); } export function shutdownWebSocketServer() { if (wsServer) { wsServer.shutDown(); (wsServer.config!.httpServer as any)[0].close(); wsServer = undefined; } } class WebSocketServerStream extends BaseStream { public constructor(private readonly connection: WebSocketConnection) { super(); if (!connection) throw new TypeError('Connection is required.'); connection.on('message', (data: any) => { if (data.type === 'binary') { this.onData(Buffer.from(data.binaryData)); } }); connection.on('close', (code, reason) => { if (!code && !reason) { this.onEnd(); } else { const error = new Error(reason); (error).code = code; this.onError(new Error(reason)); } }); } public async write(data: Buffer): Promise { if (!data) throw new TypeError('Data is required.'); this.connection.send(data); } public async close(error?: Error): Promise { if (!error) { this.connection.close(); } else { const code = typeof (error).code === 'number' ? (error).code : undefined; this.connection.drop(code, error.message); } this.onError(error || new Error('Stream closed.')); } } dev-tunnels-0.0.25/ts/test/tunnels-test/host.ts000066400000000000000000000046211450757157500214640ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { TunnelRelayTunnelHost } from '@microsoft/dev-tunnels-connections'; import { Tunnel, TunnelAccessControlEntry, TunnelAccessControlEntryType, TunnelConnectionMode, } from '@microsoft/dev-tunnels-contracts'; import { TunnelManagementHttpClient, TunnelRequestOptions } from '@microsoft/dev-tunnels-management'; import * as yargs from 'yargs'; import * as https from 'https'; const userAgent = { name: 'test-connection', version: '1.0' }; main() .then((exitCode) => process.exit(exitCode)) .catch((e) => { console.error(e); process.exit(1); }); async function main() { const argv = await yargs.argv; let optionsArray = ((argv.o || argv.option) as string | string[]) || []; if (!Array.isArray(optionsArray)) { optionsArray = [optionsArray]; } const options: { [name: string]: string } = {}; for (let i = optionsArray.length - 1; i >= 0; i--) { const nameAndValue = optionsArray[i].split('='); if (nameAndValue.length === 2) { options[nameAndValue[0]] = nameAndValue[1]; } } return startTunnelRelayHost(); } async function startTunnelRelayHost() { let tunnelManagementClient = new TunnelManagementHttpClient( userAgent, () => Promise.resolve('Bearer'), 'http://localhost:9900/', //'https://ci.dev.tunnels.vsengsaas.visualstudio.com/', new https.Agent({ rejectUnauthorized: false, }), ); let tunnelAccessControlEntry: TunnelAccessControlEntry = { type: TunnelAccessControlEntryType.Anonymous, subjects: [], scopes: ['connect'], }; const tunnel: Tunnel = { clusterId: 'westus2', ports: [{ portNumber: 8000, protocol: 'auto' }], accessControl: { entries: [tunnelAccessControlEntry], } }; let tunnelRequestOptions: TunnelRequestOptions = { tokenScopes: ['host'], includePorts: true, }; let tunnelInstance = await tunnelManagementClient.createTunnel(tunnel, tunnelRequestOptions); let host = new TunnelRelayTunnelHost(tunnelManagementClient); host.trace = (level, eventId, msg, err) => { console.log(msg); }; await host.start(tunnelInstance!); // Wait indefinitely so the connection does not close await new Promise(() => {}); return 0; } dev-tunnels-0.0.25/ts/test/tunnels-test/mocks/000077500000000000000000000000001450757157500212505ustar00rootroot00000000000000dev-tunnels-0.0.25/ts/test/tunnels-test/mocks/mockTunnelManagementClient.ts000066400000000000000000000165301450757157500271000ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { TunnelManagementClient, TunnelRequestOptions } from '@microsoft/dev-tunnels-management'; import { Tunnel, TunnelRelayTunnelEndpoint, TunnelPort, TunnelConnectionMode, TunnelEndpoint, ClusterDetails, NamedRateStatus, } from '@microsoft/dev-tunnels-contracts'; export class MockTunnelManagementClient implements TunnelManagementClient { private idCounter: number = 0; public tunnels: Tunnel[] = []; public hostRelayUri?: string; public clientRelayUri?: string; listTunnels( clusterId?: string, domain?: string, options?: TunnelRequestOptions, ): Promise { let tunnels = this.tunnels; if (options?.tags) { if (!options.requireAllTags) { tunnels = this.tunnels.filter( (tunnel) => tunnel.tags && options.tags!.some((t) => tunnel.tags!.includes(t)), ); } else { tunnels = this.tunnels.filter( (tunnel) => tunnel.tags && options.tags!.every((t) => tunnel.tags!.includes(t)), ); } } return Promise.resolve(tunnels); } async getTunnel(tunnel: Tunnel, options?: TunnelRequestOptions): Promise { const clusterId = tunnel.clusterId; const tunnelId = tunnel.tunnelId; const name = tunnel.name; const t = this.tunnels.find( (t) => (name && (t.name === name || t.tunnelId === name)) || (t.clusterId === clusterId && t.tunnelId === tunnelId), ); if (!t) { return null; } this.issueMockTokens(t, options); return t; } async createTunnel(tunnel: Tunnel, options?: TunnelRequestOptions): Promise { if (await this.getTunnel(tunnel, options)) { throw new Error('Tunnel already exists.'); } tunnel.tunnelId = 'tunnel' + ++this.idCounter; tunnel.clusterId = 'localhost'; this.tunnels.push(tunnel); this.issueMockTokens(tunnel, options); return tunnel; } updateTunnel(tunnel: Tunnel, options?: TunnelRequestOptions): Promise { this.tunnels.forEach((t) => { if (t.clusterId == tunnel.clusterId && t.tunnelId == tunnel.tunnelId) { if (tunnel.name) { t.name = tunnel.name; } if (tunnel.options) { t.options = tunnel.options; } if (tunnel.accessControl) { t.accessControl = tunnel.accessControl; } } }); this.issueMockTokens(tunnel, options); return Promise.resolve(tunnel); } deleteTunnel(tunnel: Tunnel, options?: TunnelRequestOptions): Promise { for (let i = 0; i < this.tunnels.length; i++) { let t = this.tunnels[i]; if (t.clusterId == tunnel.clusterId && t.tunnelId == tunnel.tunnelId) { //this.tunnels.RemoveAt(i); return new Promise((resolve) => { resolve(true); }); } } return Promise.resolve(false); } updateTunnelEndpoint( tunnel: Tunnel, endpoint: TunnelEndpoint, options?: TunnelRequestOptions, ): Promise { if (!tunnel.endpoints) { tunnel.endpoints = []; } for (let i = 0; i < tunnel.endpoints.length; i++) { if ( tunnel.endpoints[i].hostId == endpoint.hostId && tunnel.endpoints[i].connectionMode == endpoint.connectionMode ) { tunnel.endpoints[i] = endpoint; return new Promise((resolve) => { resolve(endpoint); }); } } let newArray: TunnelEndpoint[] = Object.assign([], tunnel.endpoints); newArray.push(endpoint); tunnel.endpoints = newArray; let tunnelEndpoint: TunnelRelayTunnelEndpoint = endpoint; if (tunnelEndpoint) { tunnelEndpoint.hostRelayUri = this.hostRelayUri; tunnelEndpoint.clientRelayUri = this.clientRelayUri; } return Promise.resolve(endpoint); } deleteTunnelEndpoints( tunnel: Tunnel, hostId: string, connectionMode?: TunnelConnectionMode, options?: TunnelRequestOptions, ): Promise { if (!hostId) { throw new Error('Host ID cannot be empty'); } if (!tunnel.endpoints) { return new Promise((resolve) => { resolve(false); }); } let initialLength = tunnel.endpoints.length; tunnel.endpoints = tunnel.endpoints.filter( (ep) => ep.hostId == hostId && (connectionMode == null || ep.connectionMode == connectionMode), ); return Promise.resolve(tunnel.endpoints!.length < initialLength); } listTunnelPorts(tunnel: Tunnel, options?: TunnelRequestOptions): Promise { throw new Error('Method not implemented.'); } getTunnelPort( tunnel: Tunnel, portNumber: number, options?: TunnelRequestOptions, ): Promise { throw new Error('Method not implemented.'); } createTunnelPort( tunnel: Tunnel, tunnelPort: TunnelPort, options?: TunnelRequestOptions, ): Promise { tunnelPort = { tunnelId: tunnel.tunnelId, clusterId: tunnel.clusterId, portNumber: tunnelPort.portNumber, protocol: tunnelPort.protocol, accessControl: tunnelPort.accessControl, options: tunnelPort.options, }; tunnel.ports = tunnel.ports ? tunnel.ports.concat(tunnelPort) : undefined; return Promise.resolve(tunnelPort); } updateTunnelPort( tunnel: Tunnel, tunnelPort: TunnelPort, options?: TunnelRequestOptions, ): Promise { throw new Error('Method not implemented.'); } deleteTunnelPort( tunnel: Tunnel, portNumber: number, options?: TunnelRequestOptions, ): Promise { if (tunnel.ports) { const tunnelPort = tunnel.ports.find((p) => p.portNumber === portNumber); if (tunnelPort) { tunnel.ports = tunnel.ports.filter((p) => p !== tunnelPort); return Promise.resolve(true); } } return Promise.resolve(false); } listUserLimits(): Promise { throw new Error('Method not implemented.'); } listClusters(): Promise { throw new Error('Method not implemented.'); } checkNameAvailablility(tunnelName: string): Promise { throw new Error('Method not implemented.'); } private issueMockTokens(tunnel: Tunnel, options?: TunnelRequestOptions) { if (tunnel && options?.tokenScopes) { tunnel.accessTokens = {}; options.tokenScopes.forEach((scope) => { tunnel.accessTokens![scope] = 'mock-token'; }); } } } dev-tunnels-0.0.25/ts/test/tunnels-test/mocks/mockTunnelRelayStreamFactory.ts000066400000000000000000000060231450757157500274410ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { TunnelRelayStreamFactory, TunnelRelayTunnelHost } from '@microsoft/dev-tunnels-connections'; import { MultiChannelStream, NodeStream, PromiseCompletionSource, SshStream, Stream, } from '@microsoft/dev-tunnels-ssh'; import { IClientConfig } from 'websocket'; export class MockTunnelRelayStreamFactory implements TunnelRelayStreamFactory { private readonly connectionType: string; private readonly stream: Stream; constructor( connectionType: string, stream: Stream, clientStreamFactory?: (stream: Stream) => Promise<{ stream: Stream, protocol: string }>, ) { this.connectionType = connectionType; this.stream = stream; if (clientStreamFactory) { this.createRelayStream = () => clientStreamFactory(this.stream); } } public createRelayStream = ( relayUri: string, protocols: string[], accessToken?: string, clientConfig?: IClientConfig, ) => { if (!relayUri || !accessToken || !protocols.includes(this.connectionType)) { throw new Error('Invalid params'); } return Promise.resolve({ stream: this.stream, protocol: this.connectionType }); }; public static from( source: Stream | PromiseLike | PromiseCompletionSource, protocol: string, ): TunnelRelayStreamFactory { return { createRelayStream: async () => { return { stream: await (source instanceof PromiseCompletionSource ? (>source).promise : Promise.resolve(source)), protocol, }; }, }; } public static fromMultiChannelStream( source: | MultiChannelStream | PromiseLike | PromiseCompletionSource, protocol: string, onClientChannelOpened?: (sshChannelStream: SshStream) => void, channelType?: string, ): TunnelRelayStreamFactory { return { createRelayStream: async () => { const multiChannelStream = await (source instanceof PromiseCompletionSource ? (>source).promise : Promise.resolve(source)); const sshChannelStream = await multiChannelStream.openStream( channelType ?? TunnelRelayTunnelHost.clientStreamChannelType, ); onClientChannelOpened?.(sshChannelStream); return { stream: new NodeStream(sshChannelStream), protocol, }; }, }; } public static throwing(error: Error): TunnelRelayStreamFactory { return { createRelayStream: () => { throw error; }, }; } } dev-tunnels-0.0.25/ts/test/tunnels-test/mocks/mockUserManager.ts000066400000000000000000000026441450757157500247110ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { UserManager } from '../userManager'; import { authenticationTokenStatus, UserInfo } from '../userInfo'; export class MockUserManager implements UserManager { public currentUser?: UserInfo; public loginUser: UserInfo = { provider: 'mock-provider', username: 'mock-user', accessToken: 'mock-access-token', tokenStatus: authenticationTokenStatus.Valid, tokenExpiration: new Date(Date.now() + 3600 * 1000 * 24), }; public getCurrentUser(): Promise { return new Promise((resolve) => { resolve( this.currentUser ?? ({ tokenStatus: authenticationTokenStatus.None, } as UserInfo), ); }); } public login(options: LoginOptions, deviceCodeCallback: Function): Promise { this.currentUser = this.loginUser; return new Promise((resolve) => { resolve(this.currentUser!); }); } public logout() { this.currentUser = { tokenStatus: authenticationTokenStatus.None, } as UserInfo; return this.currentUser; } } export class LoginOptions { public userBrowserAuth?: boolean; public useDeviceCodeAuth?: boolean; public useIntegratedWindowsAuth?: boolean; } dev-tunnels-0.0.25/ts/test/tunnels-test/package.json000066400000000000000000000004271450757157500224250ustar00rootroot00000000000000{ "name": "@microsoft/dev-tunnels-test", "description": "Tests for the tunnels library for Visual Studio tools", "keywords": [ "Tunnels" ], "scripts": { "compile": "npm run -C ../.. compile", "watch": "npm run -C ../.. watch .", "test": "npm run -C ../.. test" } } dev-tunnels-0.0.25/ts/test/tunnels-test/promiseUtils.ts000066400000000000000000000044151450757157500232070ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. /** * Error thrown by `withTimeout()` when the promise does not resolve before the timeout duration. */ export class TimeoutError extends Error { constructor(message?: string) { super(message ?? 'Operation timed out.'); (this).code = 'ETIMEDOUT'; } } /** * Returns a new promise that either resolves to the result of the original promise * or rejects with a `TimeoutError` if the original promise did not complete before * the specified timeout. */ export async function withTimeout(promise: Promise, timeoutMs: number): Promise { // Construct the timeout error object ahead of time to capture a better stack trace. const timeoutError = new TimeoutError(); let timeoutRegistration: NodeJS.Timeout | undefined; const result = await Promise.race([ promise, new Promise((_, reject) => { timeoutRegistration = setTimeout(() => reject(timeoutError), timeoutMs); }), ]); clearTimeout(timeoutRegistration!); return result; } /** * Returns a promise that resolves when some condition is satisfied, with an optional * timeout after which the promise will reject with a `TimeoutError`. */ export async function until( condition: () => boolean | Promise, timeoutMs?: number, ): Promise { const promise = new Promise(async (resolve, _) => { while (!(await condition())) { await new Promise((c) => setTimeout(c, 10)); } resolve(); }); if (typeof timeoutMs === 'number' && timeoutMs > 0) { await withTimeout(promise, timeoutMs); } else { await promise; } } /** * If the promise resolves successfully, returns `null`. * If the promise rejects with an error having the expected code, returns the error. * If the promise rejects with any other error, that error is re-thrown. */ export async function expectError( promise: Promise, code: string | string[], ): Promise { try { await promise; return null; } catch (e: any) { if (Array.isArray(code) ? code.includes(e.code) : e.code === code) { return e; } else { throw e; } } } dev-tunnels-0.0.25/ts/test/tunnels-test/starthost.ps1000066400000000000000000000007151450757157500226170ustar00rootroot00000000000000echo 'Start host' $Env:ASPNETCORE_ENVIRONMENT = "Development" echo 'Settings run location' Set-Location -Path '../../../../../../../bin/debug/TunnelService.ControlPlane/' echo 'Copy Settings' copy -Recurse -Force '../../../src/Settings/' './../../Settings/' try { echo 'Run control plane service' dotnet Microsoft.VsSaaS.Services.TunnelService.ControlPlane.dll } catch [Exception] { echo $_.Exception.GetType().FullName, $_.Exception.Message } dev-tunnels-0.0.25/ts/test/tunnels-test/testMultiChannelStream.ts000066400000000000000000000012741450757157500251470ustar00rootroot00000000000000import { CancellationToken, MultiChannelStream, SshStream, Stream } from "@microsoft/dev-tunnels-ssh"; export class TestMultiChannelStream extends MultiChannelStream { constructor( public readonly serverStream: Stream, public readonly clientStream: Stream, ) { super(serverStream); } public streamsOpened = 0; public async openStream(channelType?: string, cancellation?: CancellationToken): Promise { const result = super.openStream(channelType, cancellation); this.streamsOpened++; return result; } public dropConnection() { this.serverStream.dispose(); this.clientStream.dispose(); } }dev-tunnels-0.0.25/ts/test/tunnels-test/testTunnelRelayTunnelClient.ts000066400000000000000000000014431450757157500261750ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { TunnelRelayTunnelClient } from "@microsoft/dev-tunnels-connections"; import { TunnelManagementClient } from "@microsoft/dev-tunnels-management"; /** * Test TunnelRelayTunnelClient that exposes protected members for testing. */ export class TestTunnelRelayTunnelClient extends TunnelRelayTunnelClient { constructor(managementClient?: TunnelManagementClient) { super(undefined, managementClient); } public get isSshSessionActiveProperty(): boolean { return this.isSshSessionActive; } public get sshSessionClosedEvent() { return this.sshSessionClosed; } public hasForwardedChannels(port: number): boolean { return super.hasForwardedChannels(port); } }dev-tunnels-0.0.25/ts/test/tunnels-test/tsconfig.json000066400000000000000000000006041450757157500226430ustar00rootroot00000000000000{ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "../../out/lib/tunnels-test", "tsBuildInfoFile": "../../out/lib/tunnels-test/tsbuildinfo.json", "rootDir": "." }, "include": [ "**/*.ts", "package.json" ], "references": [ { "path": "../../src/contracts" }, { "path": "../../src/management" }, { "path": "../../src/connections" } ] } dev-tunnels-0.0.25/ts/test/tunnels-test/tunnelAccessTokenPropertiesTests.ts000066400000000000000000000052431450757157500272400ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import * as assert from 'assert'; import { suite, test } from '@testdeck/mocha'; import { TunnelAccessTokenProperties } from '@microsoft/dev-tunnels-management'; import { Tunnel } from '@microsoft/dev-tunnels-contracts'; const tunnel: Tunnel = { accessTokens: { 'woof': 'dog', 'meow purr': 'cat' }, }; @suite export class TunnelAccessTokenPropertiesTests { @test public getTunnelAccessToken() { assert.strictEqual(TunnelAccessTokenProperties.getTunnelAccessToken(tunnel, ['woof']), 'dog'); assert.strictEqual(TunnelAccessTokenProperties.getTunnelAccessToken(tunnel, 'woof'), 'dog'); assert.strictEqual(TunnelAccessTokenProperties.getTunnelAccessToken(tunnel, ['beep', 'woof']), 'dog'); assert.strictEqual(TunnelAccessTokenProperties.getTunnelAccessToken(tunnel, ['woof', 'meow']), 'dog'); assert.strictEqual(TunnelAccessTokenProperties.getTunnelAccessToken(tunnel, ['beep', 'meow']), 'cat'); assert.strictEqual(TunnelAccessTokenProperties.getTunnelAccessToken(tunnel, ['meow']), 'cat'); assert.strictEqual(TunnelAccessTokenProperties.getTunnelAccessToken(tunnel, 'meow'), 'cat'); assert.strictEqual(TunnelAccessTokenProperties.getTunnelAccessToken(tunnel, ['purr']), 'cat'); assert.strictEqual(TunnelAccessTokenProperties.getTunnelAccessToken(tunnel, 'purr'), 'cat'); assert.strictEqual(TunnelAccessTokenProperties.getTunnelAccessToken(tunnel, ['meow', 'woof']), 'cat'); assert.strictEqual(TunnelAccessTokenProperties.getTunnelAccessToken(tunnel, ['purr', 'woof']), 'cat'); assert.strictEqual(TunnelAccessTokenProperties.getTunnelAccessToken(undefined, ['woof']), undefined); assert.strictEqual(TunnelAccessTokenProperties.getTunnelAccessToken(undefined, 'woof'), undefined); assert.strictEqual(TunnelAccessTokenProperties.getTunnelAccessToken(tunnel, ['beep']), undefined); assert.strictEqual(TunnelAccessTokenProperties.getTunnelAccessToken(tunnel, 'beep'), undefined); assert.strictEqual(TunnelAccessTokenProperties.getTunnelAccessToken(tunnel, []), undefined); assert.strictEqual(TunnelAccessTokenProperties.getTunnelAccessToken(tunnel, ''), undefined); assert.strictEqual(TunnelAccessTokenProperties.getTunnelAccessToken(tunnel, ' '), undefined); assert.strictEqual(TunnelAccessTokenProperties.getTunnelAccessToken(tunnel, 'meow purr'), undefined); assert.strictEqual(TunnelAccessTokenProperties.getTunnelAccessToken(tunnel, undefined), undefined); assert.strictEqual(TunnelAccessTokenProperties.getTunnelAccessToken(undefined, undefined), undefined); } }dev-tunnels-0.0.25/ts/test/tunnels-test/tunnelHostAndClientTests.ts000066400000000000000000001361511450757157500254630ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import * as assert from 'assert'; import { until, withTimeout } from './promiseUtils'; import { suite, test, params, slow, timeout } from '@testdeck/mocha'; import { MockTunnelManagementClient } from './mocks/mockTunnelManagementClient'; import { ForwardedPortConnectingEventArgs, PortForwardingService, } from '@microsoft/dev-tunnels-ssh-tcp'; import { Tunnel, TunnelPort, TunnelConnectionMode, TunnelAccessScopes, TunnelRelayTunnelEndpoint, } from '@microsoft/dev-tunnels-contracts'; import { ConnectionStatus, RelayConnectionError, RelayErrorType, TunnelConnection, TunnelRelayTunnelClient, TunnelRelayTunnelHost, } from '@microsoft/dev-tunnels-connections'; import { CancellationError, KeyPair, NodeStream, ObjectDisposedError, PromiseCompletionSource, SshAlgorithms, SshAuthenticationType, SshClientCredentials, SshClientSession, SshDisconnectReason, SshServerCredentials, SshServerSession, SshSessionConfiguration, SshStream, Stream, } from '@microsoft/dev-tunnels-ssh'; import { DuplexStream } from './duplexStream'; import * as net from 'net'; import { MockTunnelRelayStreamFactory } from './mocks/mockTunnelRelayStreamFactory'; import { TestTunnelRelayTunnelClient } from './testTunnelRelayTunnelClient'; import { TestMultiChannelStream } from './testMultiChannelStream'; import { CancellationToken, CancellationTokenSource, Disposable } from 'vscode-jsonrpc'; import { TunnelConnectionOptions } from 'src/connections/tunnelConnectionOptions'; interface TestConnection { relayHost: TunnelRelayTunnelHost; relayClient: TestTunnelRelayTunnelClient; managementClient: MockTunnelManagementClient; clientMultiChannelStream: TestMultiChannelStream; clientStream: SshStream | undefined; dispose(): Promise; addPortOnHostAndValidateOnClient(portNumber: number): Promise; } @suite @slow(3000) @timeout(10000) export class TunnelHostAndClientTests { private mockHostRelayUri: string = 'ws://localhost/tunnel/host'; private mockClientRelayUri: string = 'ws://localhost/tunnel/client'; @slow(10000) @timeout(20000) public static async before() {} public static async after() {} private createRelayTunnel(ports?: number[], dontAddClientEndpoint?: boolean): Tunnel { return { tunnelId: 'test', clusterId: 'localhost', accessTokens: { [TunnelAccessScopes.Host]: 'mock-host-token', [TunnelAccessScopes.Connect]: 'mock-connect-token', }, endpoints: dontAddClientEndpoint ? [] : [ { connectionMode: TunnelConnectionMode.TunnelRelay, clientRelayUri: this.mockClientRelayUri, } as TunnelRelayTunnelEndpoint, ], ports: ports ? ports.map((p) => { return { portNumber: p } as TunnelPort; }) : [], } as Tunnel; } private async createSshServerSession(serverSshKey?: KeyPair): Promise { let sshConfig = new SshSessionConfiguration(); sshConfig.addService(PortForwardingService); let sshSession = new SshServerSession(sshConfig); sshSession.credentials = { publicKeys: [serverSshKey] } as SshServerCredentials; sshSession.onAuthenticating((e) => { // SSH client authentication is not yet implemented, so for now only the // "none" authentication type is supported. if (e.authenticationType === SshAuthenticationType.clientNone) { e.authenticationPromise = Promise.resolve({}); } }); return sshSession; } private createSshClientSession(): SshClientSession { let sshConfig = new SshSessionConfiguration(); sshConfig.addService(PortForwardingService); let sshSession = new SshClientSession(sshConfig); sshSession.onAuthenticating((e) => { // SSH server (host public key) authentication is not yet implemented. e.authenticationPromise = Promise.resolve({}); }); sshSession.onRequest((e) => { e.isAuthorized = e.request.requestType == 'tcpip-forward' || e.request.requestType == 'cancel-tcpip-forward'; }); return sshSession; } // Connects a relay client to a duplex stream and returns the SSH server session // on the other end of the stream. private async connectRelayClient( relayClient: TestTunnelRelayTunnelClient, tunnel: Tunnel, connectionOptions?: TunnelConnectionOptions, clientStreamFactory?: (stream: Stream) => Promise<{ stream: Stream, protocol: string }>, serverSshKey?: KeyPair, cancellation?: CancellationToken, ): Promise { const [serverStream, clientStream] = await DuplexStream.createStreams(); serverSshKey ??= await SshAlgorithms.publicKey.ecdsaSha2Nistp384!.generateKeyPair(); let sshSession = await this.createSshServerSession(serverSshKey); let serverConnectPromise = sshSession.connect(serverStream); relayClient.streamFactory = new MockTunnelRelayStreamFactory( TunnelRelayTunnelClient.webSocketSubProtocol, clientStream, clientStreamFactory, ); assert.strictEqual(relayClient.isSshSessionActiveProperty, false); await relayClient.connect(tunnel, connectionOptions, cancellation); await serverConnectPromise; assert.strictEqual(relayClient.isSshSessionActiveProperty, true); return sshSession; } private async connectRelayHost( relayHost: TunnelRelayTunnelHost, tunnel: Tunnel, connectionOptions?: TunnelConnectionOptions, clientStreamFactory?: (stream: Stream) => Promise<{ stream: Stream, protocol: string }>, cancellation?: CancellationToken, ): Promise { const [serverStream, clientStream] = await DuplexStream.createStreams(); let multiChannelStream = new TestMultiChannelStream(serverStream, clientStream); let serverConnectPromise = multiChannelStream.connect(); relayHost.streamFactory = new MockTunnelRelayStreamFactory( TunnelRelayTunnelHost.webSocketSubProtocol, clientStream, clientStreamFactory, ); await relayHost.connect(tunnel, connectionOptions, cancellation); await serverConnectPromise; return multiChannelStream; } @test public async connectRelayClientTest() { let relayClient = new TestTunnelRelayTunnelClient(); assert.strictEqual(relayClient.disconnectError, undefined); assert.strictEqual(relayClient.connectionStatus, ConnectionStatus.None); relayClient.connectionModes.forEach((connectionMode) => { assert.strictEqual(connectionMode, TunnelConnectionMode.TunnelRelay); }); let sshSessionClosedEventFired = false; relayClient.sshSessionClosedEvent((e) => (sshSessionClosedEventFired = true)); let tunnel = this.createRelayTunnel(); await this.connectRelayClient(relayClient, tunnel); assert.strictEqual(sshSessionClosedEventFired, false); await relayClient.dispose(); assert.strictEqual(relayClient.isSshSessionActiveProperty, false); assert.strictEqual(sshSessionClosedEventFired, true); assert.strictEqual(relayClient.disconnectError, undefined); assert.strictEqual(relayClient.connectionStatus, ConnectionStatus.Disconnected); } @test public async connectRelayClientAfterDisconnect() { const tunnel = this.createRelayTunnel(); const relayClient = new TestTunnelRelayTunnelClient(); try { const serverSshSession = await this.connectRelayClient(relayClient, tunnel); assert(!relayClient.disconnectError); const disconnectedCompletion = new PromiseCompletionSource(); relayClient.connectionStatusChanged((e) => { if (e.status === ConnectionStatus.Disconnected) { disconnectedCompletion.resolve(); } }); await serverSshSession.close(SshDisconnectReason.byApplication); await withTimeout(disconnectedCompletion.promise, 5000); await this.connectRelayClient(relayClient, tunnel); } finally { relayClient.dispose(); } } @test public async connectRelayClientAfterFail() { const tunnel = this.createRelayTunnel(); const relayClient = new TestTunnelRelayTunnelClient(); try { let error: Error | undefined = undefined; try { await this.connectRelayClient(relayClient, tunnel, undefined, (s) => { throw new Error('Test error'); }); } catch (e) { error = e; } assert(error?.message?.includes('Test error')); await this.connectRelayClient(relayClient, tunnel); } finally { relayClient.dispose(); } } @test public async connectRelayClientAfterCancel() { const tunnel = this.createRelayTunnel(); const relayClient = new TestTunnelRelayTunnelClient(); try { const cancellationSource = new CancellationTokenSource(); let error: Error | undefined = undefined; try { await this.connectRelayClient(relayClient, tunnel, undefined, (s) => { cancellationSource.cancel(); throw new RelayConnectionError( 'error.tooManyRequests', { errorType: RelayErrorType.ConnectionError, statusCode: 429 }, ); }, undefined, cancellationSource.token); } catch (e) { error = e; } assert(error instanceof CancellationError); await this.connectRelayClient(relayClient, tunnel); } finally { relayClient.dispose(); } } @test public async connectRelayClientDispose() { const tunnel = this.createRelayTunnel(); const relayClient = new TestTunnelRelayTunnelClient(); let error: Error | undefined = undefined; try { await this.connectRelayClient(relayClient, tunnel, undefined, (s) => { relayClient.dispose(); throw new RelayConnectionError( 'error.tooManyRequests', { errorType: RelayErrorType.ConnectionError, statusCode: 429 }, ); }); } catch (e) { error = e; } assert(error instanceof ObjectDisposedError); } @test public async connectRelayClientAfterDispose() { const tunnel = this.createRelayTunnel(); const relayClient = new TestTunnelRelayTunnelClient(); relayClient.dispose(); let error: Error | undefined = undefined; try { await this.connectRelayClient(relayClient, tunnel); } catch (e) { error = e; } assert(error instanceof ObjectDisposedError); } @test @params({ enableRetry: true }) @params({ enableRetry: false }) @params.naming((params) => `connectRelayClientRetriesOn429(enableRetry: ${params.enableRetry})`) public async connectRelayClientRetriesOn429(connectionOptions: TunnelConnectionOptions) { const relayClient = new TestTunnelRelayTunnelClient(); const tunnel = this.createRelayTunnel(); let firstAttempt = true; const connected = this.connectionStatusChanged(relayClient, ConnectionStatus.Connected); let error: Error | undefined = undefined; try { await this.connectRelayClient(relayClient, tunnel, connectionOptions, async (stream) => { if (firstAttempt) { firstAttempt = false; throw new RelayConnectionError('error.tooManyRequests', { errorType: RelayErrorType.TooManyRequests, statusCode: 429, }); } return { stream, protocol: TunnelRelayTunnelClient.webSocketSubProtocol }; }); } catch (e) { error = e; } if (connectionOptions.enableRetry) { assert(!error); } else { assert(error?.message?.includes('error.tooManyRequests')); return; } assert.strictEqual(await connected, undefined); assert.strictEqual(relayClient.disconnectError, undefined); assert.strictEqual(relayClient.connectionStatus, ConnectionStatus.Connected); const disconnected = this.connectionStatusChanged( relayClient, ConnectionStatus.Disconnected, ); await relayClient.dispose(); assert.strictEqual(await disconnected, undefined); assert.strictEqual(relayClient.disconnectError, undefined); assert.strictEqual(relayClient.connectionStatus, ConnectionStatus.Disconnected); } @test public connectRelayClientFailsForUnrecoverableError() { return this.connectRelayClientFailsForError(new Error('Unrecoverable Error')); } @test public connectRelayClientFailsFor403ForbiddenError() { const error = new RelayConnectionError('error.relayClientForbidden', { errorType: RelayErrorType.Unauthorized, statusCode: 403, }); return this.connectRelayClientFailsForError( error, "Forbidden (403). Provide a fresh tunnel access token with 'connect' scope.", ); } @test public connectRelayClientFailsFor401UnauthorizedError() { const error = new RelayConnectionError('error.relayClientUnauthorized', { errorType: RelayErrorType.Unauthorized, statusCode: 401, }); return this.connectRelayClientFailsForError( error, "Not authorized (401). Provide a fresh tunnel access token with 'connect' scope.", ); } @test async connectRelayClientWithValidHostKey() { // A good tunnel with the correct host public key. const serverSshKey = await SshAlgorithms.publicKey.ecdsaSha2Nistp384!.generateKeyPair(); const publicKeyBuffer = await serverSshKey.getPublicKeyBytes(serverSshKey.keyAlgorithmName); const tunnel = this.createRelayTunnel(); tunnel.endpoints![0].hostPublicKeys = [publicKeyBuffer!.toString('base64')]; const relayClient = new TestTunnelRelayTunnelClient(); const serverSession = await this.connectRelayClient( relayClient, tunnel, undefined, undefined, serverSshKey); assert.strictEqual(relayClient.connectionStatus, ConnectionStatus.Connected, 'Client must be connected.'); assert.strictEqual(serverSession.isConnected, true, 'Server SSH session must be connected.'); relayClient.dispose() serverSession.dispose(); } @test async connectRelayClientWithStaleHostKey() { // A good tunnel with the correct host public key. const serverSshKey = await SshAlgorithms.publicKey.ecdsaSha2Nistp384!.generateKeyPair(); const publicKeyBuffer = await serverSshKey.getPublicKeyBytes(serverSshKey.keyAlgorithmName); const tunnel = this.createRelayTunnel(); tunnel.endpoints![0].hostPublicKeys = [publicKeyBuffer!.toString('base64')]; // Management client can fetch the good tunnel. const managementClient = new MockTunnelManagementClient(); managementClient.tunnels = [tunnel]; // Client tries to connect to a stale tunnel with outdated host public key. const staleTunnel = this.createRelayTunnel(); staleTunnel.endpoints![0].hostPublicKeys = ['staleToken']; const relayClient = new TestTunnelRelayTunnelClient(managementClient); let isHostPublicKeyRefreshed = false; relayClient.connectionStatusChanged( (e) => isHostPublicKeyRefreshed ||= (e.status === ConnectionStatus.RefreshingTunnelHostPublicKey)); const serverSession = await this.connectRelayClient( relayClient, staleTunnel, undefined, undefined, serverSshKey); // Client should be connected after refreshing host public key. assert.strictEqual(isHostPublicKeyRefreshed, true, 'Client must have refreshed host public keys.'); assert.strictEqual(relayClient.connectionStatus, ConnectionStatus.Connected, 'Client must be connected.'); assert.strictEqual(serverSession.isConnected, true, 'Server SSH session must be connected.'); relayClient.dispose() serverSession.dispose(); } @test async connectRelayClientWithStaleHostKeyTunnelIsMissing() { // Management client cannot fetch the tunnel. const managementClient = new MockTunnelManagementClient(); // Client tries to connect to a stale tunnel with outdated host public key. const staleTunnel = this.createRelayTunnel(); staleTunnel.endpoints![0].hostPublicKeys = ['staleToken']; const relayClient = new TestTunnelRelayTunnelClient(managementClient); let isHostPublicKeyRefreshed = false; relayClient.connectionStatusChanged((e) => isHostPublicKeyRefreshed ||= (e.status === ConnectionStatus.RefreshingTunnelHostPublicKey)); await assert.rejects( () => this.connectRelayClient(relayClient, staleTunnel), (e) => (e).message === 'Failed to connect to tunnel relay. Error: SSH server authentication failed.'); assert.strictEqual(isHostPublicKeyRefreshed, true, 'Client must have tried to refresh the host public key.'); assert.strictEqual(relayClient.connectionStatus, ConnectionStatus.Disconnected, 'Client must be disconnected.'); relayClient.dispose() } @test async connectRelayClientWithStaleHostKeyNoTunnelManagementClient() { // Client tries to connect to a stale tunnel with outdated host public key. const staleTunnel = this.createRelayTunnel(); staleTunnel.endpoints![0].hostPublicKeys = ['staleToken']; const relayClient = new TestTunnelRelayTunnelClient(); let isHostPublicKeyRefreshed = false; relayClient.connectionStatusChanged((e) => isHostPublicKeyRefreshed ||= (e.status === ConnectionStatus.RefreshingTunnelHostPublicKey)); await assert.rejects( () => this.connectRelayClient(relayClient, staleTunnel), (e) => (e).message === 'Failed to connect to tunnel relay. Error: SSH server authentication failed.'); assert.strictEqual(isHostPublicKeyRefreshed, false, 'Client must not have tried to refresh the host public key.'); assert.strictEqual(relayClient.connectionStatus, ConnectionStatus.Disconnected, 'Client must be disconnected.'); relayClient.dispose() } private async connectRelayClientFailsForError(error: Error, expectedErrorMessage?: string) { const relayClient = new TestTunnelRelayTunnelClient(); const tunnel = this.createRelayTunnel(); const disconnectError = this.connectionStatusChanged( relayClient, ConnectionStatus.Disconnected, ); // Connecting wraps error in a new error object with this error message const expectedConnectErrorMessage = `Failed to connect to tunnel relay. Error: ${expectedErrorMessage ?? error.message}`; try { await this.connectRelayClient(relayClient, tunnel, undefined, () => { throw error; }); } catch (e) { assert.strictEqual((e as Error).message, expectedConnectErrorMessage); } // connectionStatusChanged event and disconnectError contain the original error. assert.strictEqual(await disconnectError, error); assert.strictEqual(relayClient.disconnectError, error); assert.strictEqual(relayClient.connectionStatus, ConnectionStatus.Disconnected); } @params({ localAddress: '0.0.0.0' }) @params({ localAddress: '127.0.0.1' }) @params.naming((params) => 'connectRelayClientAddPort: ' + params.localAddress) public async connectRelayClientAddPort({ localAddress }: { localAddress: string }) { const relayClient = new TestTunnelRelayTunnelClient(); relayClient.localForwardingHostAddress = localAddress; relayClient.acceptLocalConnectionsForForwardedPorts = false; let tunnel = this.createRelayTunnel(); let serverSshSession = await this.connectRelayClient(relayClient, tunnel); let pfs = serverSshSession.activateService(PortForwardingService); let testPort = 9881; assert.strictEqual(relayClient.hasForwardedChannels(testPort), false); let remotePortStreamer = await pfs.streamFromRemotePort('127.0.0.1', testPort); assert.notStrictEqual(remotePortStreamer, null); assert.strictEqual(testPort, remotePortStreamer!.remotePort); await relayClient.waitForForwardedPort(testPort); let tcs = new PromiseCompletionSource(); let isStreamOpenedOnServer = false; remotePortStreamer?.onStreamOpened(async (stream: SshStream) => { isStreamOpenedOnServer = true; await tcs.promise; stream.destroy(); }); const forwardedStream = await relayClient.connectToForwardedPort(testPort); assert.notStrictEqual(forwardedStream, null); assert.strictEqual(relayClient.hasForwardedChannels(testPort), true); assert.strictEqual(isStreamOpenedOnServer, true); tcs.resolve(); forwardedStream.destroy(); remotePortStreamer?.dispose(); await relayClient.dispose(); assert.strictEqual(relayClient.disconnectError, undefined); assert.strictEqual(relayClient.connectionStatus, ConnectionStatus.Disconnected); } @test public async forwardedPortConnectingRetrieveStream() { const testPort = 9986; const managementClient = new MockTunnelManagementClient(); managementClient.hostRelayUri = this.mockHostRelayUri; const relayHost = new TunnelRelayTunnelHost(managementClient); relayHost.forwardConnectionsToLocalPorts = false; let hostStream = null; relayHost.forwardedPortConnecting((e: ForwardedPortConnectingEventArgs) => { if (e.port === testPort) { hostStream = e.stream; } }); const tunnel = this.createRelayTunnel([testPort]); await managementClient.createTunnel(tunnel); const multiChannelStream = await this.connectRelayHost(relayHost, tunnel); const clientRelayStream = await multiChannelStream.openStream( TunnelRelayTunnelHost.clientStreamChannelType, ); const clientSshSession = this.createSshClientSession(); const pfs = clientSshSession.activateService(PortForwardingService); pfs.acceptLocalConnectionsForForwardedPorts = false; await clientSshSession.connect(new NodeStream(clientRelayStream)); const clientCredentials: SshClientCredentials = { username: 'tunnel', password: undefined }; await clientSshSession.authenticate(clientCredentials); await pfs.waitForForwardedPort(testPort); const clientStream = await pfs.connectToForwardedPort(testPort); assert(clientStream); assert(hostStream); clientSshSession.dispose(); multiChannelStream.dispose(); } @test public async connectRelayClientAddPortInUse() { const relayClient = new TestTunnelRelayTunnelClient(); const testPort = 9982; const tunnel = this.createRelayTunnel([testPort]); const serverSshSession = await this.connectRelayClient(relayClient, tunnel); const pfs = serverSshSession.activateService(PortForwardingService); const connectCompletion = new PromiseCompletionSource(); const conflictListener = new net.Server(); conflictListener.listen(testPort, '127.0.0.1', async () => { let remotePortStreamer = await pfs.streamFromRemotePort('127.0.0.1', testPort); // The port number should be the same because the host does not know // when the client chose a different port number due to the conflict. assert.strictEqual(testPort, remotePortStreamer?.remotePort); connectCompletion.resolve(); }); try { await withTimeout(connectCompletion.promise, 5000); } finally { conflictListener.close(); relayClient.dispose(); } } @test public async connectRelayClientRemovePort() { let relayClient = new TestTunnelRelayTunnelClient(); let tunnel = this.createRelayTunnel(); let serverSshSession = await this.connectRelayClient(relayClient, tunnel); let pfs = serverSshSession.activateService(PortForwardingService); let testPort = 9983; let remotePortStreamer = await pfs.streamFromRemotePort('::', testPort); assert.notStrictEqual(remotePortStreamer, null); assert.strictEqual(remotePortStreamer?.remotePort, testPort); // Disposing this object stops forwarding the port. remotePortStreamer?.dispose(); const socket = new net.Socket(); // Now a connection attempt should fail. try { socket.connect(testPort, '127.0.0.1', async () => {}); } catch (ex) { } finally { socket.destroy(); } } @test public async connectRelayHostTest() { let managementClient = new MockTunnelManagementClient(); managementClient.hostRelayUri = this.mockHostRelayUri; let relayHost = new TunnelRelayTunnelHost(managementClient); assert.strictEqual(relayHost.disconnectError, undefined); assert.strictEqual(relayHost.connectionStatus, ConnectionStatus.None); let tunnel = this.createRelayTunnel(); let multiChannelStream = await this.connectRelayHost(relayHost, tunnel); let clientRelayStream = await multiChannelStream.openStream( TunnelRelayTunnelHost.clientStreamChannelType, ); let clientSshSession = this.createSshClientSession(); let pfs = clientSshSession.activateService(PortForwardingService); await clientSshSession.connect(new NodeStream(clientRelayStream)); clientRelayStream.destroy(); await relayHost.dispose(); assert.strictEqual(relayHost.disconnectError, undefined); assert.strictEqual(relayHost.connectionStatus, ConnectionStatus.Disconnected); } @test public async connectRelayHostAfterDisconnect() { const managementClient = new MockTunnelManagementClient(); managementClient.hostRelayUri = this.mockHostRelayUri; const relayHost = new TunnelRelayTunnelHost(managementClient); const tunnel = this.createRelayTunnel(); const hostStream = await this.connectRelayHost(relayHost, tunnel); const disconnectedCompletion = new PromiseCompletionSource(); relayHost.connectionStatusChanged((e) => { if (e.status === ConnectionStatus.Disconnected) { disconnectedCompletion.resolve(); } }); hostStream.dispose(); await withTimeout(disconnectedCompletion.promise, 5000); await this.connectRelayHost(relayHost, tunnel); } @test public async connectRelayHostAfterFail() { const managementClient = new MockTunnelManagementClient(); managementClient.hostRelayUri = this.mockHostRelayUri; const relayHost = new TunnelRelayTunnelHost(managementClient); const tunnel = this.createRelayTunnel(); let error: Error | undefined = undefined; try { await this.connectRelayHost(relayHost, tunnel, undefined, (_) => { throw new Error('Test failure'); }); } catch (e) { error = e; } assert(error?.message?.includes('Test failure')); await this.connectRelayHost(relayHost, tunnel); } @test public async connectRelayHostAfterCancel() { const managementClient = new MockTunnelManagementClient(); managementClient.hostRelayUri = this.mockHostRelayUri; const relayHost = new TunnelRelayTunnelHost(managementClient); const tunnel = this.createRelayTunnel(); const cancellationSource = new CancellationTokenSource(); let error: Error | undefined = undefined; try { await this.connectRelayHost(relayHost, tunnel, undefined, (_) => { cancellationSource.cancel(); throw new RelayConnectionError( 'error.tooManyRequests', { errorType: RelayErrorType.ConnectionError, statusCode: 429 }, ); }, cancellationSource.token); } catch (e) { error = e; } assert(error instanceof CancellationError); await this.connectRelayHost(relayHost, tunnel); } @test public async connectRelayHostDispose() { const managementClient = new MockTunnelManagementClient(); managementClient.hostRelayUri = this.mockHostRelayUri; const relayHost = new TunnelRelayTunnelHost(managementClient); const tunnel = this.createRelayTunnel(); let error: Error | undefined = undefined; try { await this.connectRelayHost(relayHost, tunnel, undefined, (_) => { relayHost.dispose(); throw new RelayConnectionError( 'error.tooManyRequests', { errorType: RelayErrorType.ConnectionError, statusCode: 429 }, ); }); } catch (e) { error = e; } assert(error instanceof ObjectDisposedError); } @test public async connectRelayHostAfterDispose() { const managementClient = new MockTunnelManagementClient(); managementClient.hostRelayUri = this.mockHostRelayUri; const relayHost = new TunnelRelayTunnelHost(managementClient); const tunnel = this.createRelayTunnel(); relayHost.dispose(); let error: Error | undefined = undefined; try { await this.connectRelayHost(relayHost, tunnel); } catch (e) { error = e; } assert(error instanceof ObjectDisposedError); } @test @params({ enableRetry: true }) @params({ enableRetry: false }) @params.naming((params) => `connectRelayHostRetriesOn429(enableRetry: ${params.enableRetry})`) public async connectRelayHostRetriesOn429(connectionOptions: TunnelConnectionOptions) { let managementClient = new MockTunnelManagementClient(); managementClient.hostRelayUri = this.mockHostRelayUri; let relayHost = new TunnelRelayTunnelHost(managementClient); assert.strictEqual(relayHost.disconnectError, undefined); assert.strictEqual(relayHost.connectionStatus, ConnectionStatus.None); let tunnel = this.createRelayTunnel(); let firstAttempt = true; const connected = this.connectionStatusChanged(relayHost, ConnectionStatus.Connected); let error: Error | undefined = undefined; try { await this.connectRelayHost(relayHost, tunnel, connectionOptions, async (stream) => { if (firstAttempt) { firstAttempt = false; throw new RelayConnectionError('error.tooManyRequests', { errorType: RelayErrorType.TooManyRequests, statusCode: 429, }); } return { stream, protocol: TunnelRelayTunnelHost.webSocketSubProtocol }; }); } catch (e) { error = e; } if (connectionOptions.enableRetry) { assert(!error); } else { assert(error?.message?.includes('error.tooManyRequests')); return; } assert.strictEqual(await connected, undefined); assert.strictEqual(relayHost.disconnectError, undefined); assert.strictEqual(relayHost.connectionStatus, ConnectionStatus.Connected); const disconnected = this.connectionStatusChanged(relayHost, ConnectionStatus.Disconnected); await relayHost.dispose(); assert.strictEqual(await disconnected, undefined); assert.strictEqual(relayHost.disconnectError, undefined); assert.strictEqual(relayHost.connectionStatus, ConnectionStatus.Disconnected); } @test public connectRelayHostFailsForUnrecoverableError() { return this.connectRelayHostFailsForError(new Error('Unrecoverable Error')); } @test public connectRelayHostFailsFor403ForbiddenError() { const error = new RelayConnectionError('error.relayClientForbidden', { errorType: RelayErrorType.Unauthorized, statusCode: 403, }); return this.connectRelayHostFailsForError( error, "Forbidden (403). Provide a fresh tunnel access token with 'host' scope.", ); } @test public connectRelayHostFailsFor401UnauthorizedError() { const error = new RelayConnectionError('error.relayClientUnauthorized', { errorType: RelayErrorType.Unauthorized, statusCode: 401, }); // The host will try to use tunnel management client to fetch a new tunnel access token. // Since the test always rejects the web socket with 401, it'll fail with the following error message. return this.connectRelayHostFailsForError( error, 'Not authorized (401). Refreshed tunnel access token also does not work.', ); } private async connectRelayHostFailsForError(error: Error, expectedErrorMessage?: string) { const managementClient = new MockTunnelManagementClient(); managementClient.hostRelayUri = this.mockHostRelayUri; const relayHost = new TunnelRelayTunnelHost(managementClient); const tunnel = this.createRelayTunnel(); await managementClient.createTunnel(tunnel); const disconnectError = this.connectionStatusChanged( relayHost, ConnectionStatus.Disconnected, ); // Connecting wraps error in a new error object with this error message const expectedConnectErrorMessage = `Failed to connect to tunnel relay. Error: ${expectedErrorMessage ?? error.message}`; try { await this.connectRelayHost(relayHost, tunnel, undefined, () => { throw error; }); } catch (e) { assert.strictEqual((e as Error).message, expectedConnectErrorMessage); } // connectionStatusChanged event and disconnectError contain the original error. assert.strictEqual(await disconnectError, error); assert.strictEqual(relayHost.disconnectError, error); assert.strictEqual(relayHost.connectionStatus, ConnectionStatus.Disconnected); } @test public async connectRelayHostAutoAddPort() { let managementClient = new MockTunnelManagementClient(); managementClient.hostRelayUri = this.mockHostRelayUri; let relayHost = new TunnelRelayTunnelHost(managementClient); let tunnel = this.createRelayTunnel([9984]); let multiChannelStream = await this.connectRelayHost(relayHost, tunnel); let clientRelayStream = await multiChannelStream.openStream( TunnelRelayTunnelHost.clientStreamChannelType, ); let clientSshSession = this.createSshClientSession(); try { await clientSshSession.connect(new NodeStream(clientRelayStream)); let clientCredentials: SshClientCredentials = { username: 'tunnel', password: undefined }; await clientSshSession.authenticate(clientCredentials); await until(() => relayHost.remoteForwarders.size === 1, 5000); assert.strictEqual(tunnel.ports!.length, 1); const forwardedPort = tunnel.ports![0]; let forwarder = relayHost.remoteForwarders.get('9984'); if (forwarder) { assert.strictEqual(forwardedPort.portNumber, forwarder.localPort); assert.strictEqual(forwardedPort.portNumber, forwarder.remotePort); } } finally { clientRelayStream.destroy(); clientSshSession.dispose(); await relayHost.dispose(); } assert.strictEqual(relayHost.disconnectError, undefined); assert.strictEqual(relayHost.connectionStatus, ConnectionStatus.Disconnected); } @test public async ConnectRelayHostThenConnectRelayClientToDifferentPort_Fails() { let managementClient = new MockTunnelManagementClient(); managementClient.hostRelayUri = this.mockHostRelayUri; let relayHost = new TunnelRelayTunnelHost(managementClient); let testPort = 9886; let differentPort = 9887; let tunnel = this.createRelayTunnel([testPort]); await managementClient.createTunnel(tunnel); let multiChannelStream = await this.connectRelayHost(relayHost, tunnel); let clientRelayStream = await multiChannelStream.openStream( TunnelRelayTunnelHost.clientStreamChannelType, ); let clientSshSession = this.createSshClientSession(); let pfs = clientSshSession.activateService(PortForwardingService); await clientSshSession.connect(new NodeStream(clientRelayStream)); let clientCredentials: SshClientCredentials = { username: 'tunnel', password: undefined }; await clientSshSession.authenticate(clientCredentials); await pfs.waitForForwardedPort(testPort); await assert.rejects(pfs.connectToForwardedPort(differentPort)); clientSshSession.dispose(); multiChannelStream.dispose(); } @test public async connectRelayHostAddPort() { let managementClient = new MockTunnelManagementClient(); managementClient.hostRelayUri = this.mockHostRelayUri; let relayHost = new TunnelRelayTunnelHost(managementClient); let tunnel = this.createRelayTunnel(); await managementClient.createTunnel(tunnel); let multiChannelStream = await this.connectRelayHost(relayHost, tunnel); let clientRelayStream = await multiChannelStream.openStream( TunnelRelayTunnelHost.clientStreamChannelType, ); let clientSshSession = this.createSshClientSession(); await clientSshSession.connect(new NodeStream(clientRelayStream)); let clientCredentials: SshClientCredentials = { username: 'tunnel', password: undefined }; await clientSshSession.authenticate(clientCredentials); await managementClient.createTunnelPort(tunnel, { portNumber: 9985 }); await relayHost.refreshPorts(); assert.strictEqual(tunnel.ports!.length, 1); const forwardedPort = tunnel.ports![0]; let forwarder = relayHost.remoteForwarders.get('9985'); if (forwarder) { assert.strictEqual(forwardedPort.portNumber, forwarder.localPort); assert.strictEqual(forwardedPort.portNumber, forwarder.remotePort); } clientRelayStream.destroy(); clientSshSession.dispose(); } @test public async connectRelayHostRemovePort() { let managementClient = new MockTunnelManagementClient(); managementClient.hostRelayUri = this.mockHostRelayUri; let relayHost = new TunnelRelayTunnelHost(managementClient); let tunnel = this.createRelayTunnel([9986]); await managementClient.createTunnel(tunnel); let multiChannelStream = await this.connectRelayHost(relayHost, tunnel); let clientRelayStream = await multiChannelStream.openStream( TunnelRelayTunnelHost.clientStreamChannelType, ); let clientSshSession = this.createSshClientSession(); clientSshSession.activateService(PortForwardingService) .acceptLocalConnectionsForForwardedPorts = false; try { await clientSshSession.connect(new NodeStream(clientRelayStream)); let clientCredentials: SshClientCredentials = { username: 'tunnel', password: undefined }; await clientSshSession.authenticate(clientCredentials); await until(() => relayHost.remoteForwarders.size === 1, 5000); await managementClient.deleteTunnelPort(tunnel, 9986); await relayHost.refreshPorts(); assert.strictEqual(tunnel.ports!.length, 0); assert.strictEqual(relayHost.remoteForwarders.size, 1); } finally { clientSshSession.dispose(); await relayHost.dispose(); } } @test public async connectRelayClientToHostAndReconnectHost() { const testConnection = await this.startHostWithClientAndAddPort(); const { relayHost, relayClient, clientMultiChannelStream } = testConnection; // Reconnect the tunnel host const reconnectedHostStream = new PromiseCompletionSource(); relayHost.streamFactory = MockTunnelRelayStreamFactory.from( reconnectedHostStream, TunnelRelayTunnelHost.webSocketSubProtocol); const reconnectedClientMultiChannelStream = new PromiseCompletionSource< TestMultiChannelStream >(); relayClient.streamFactory = MockTunnelRelayStreamFactory.fromMultiChannelStream( reconnectedClientMultiChannelStream, TunnelRelayTunnelClient.webSocketSubProtocol, ); clientMultiChannelStream.dropConnection(); const [serverStream, clientStream] = await DuplexStream.createStreams(); let newMultiChannelStream = new TestMultiChannelStream(serverStream, clientStream); let serverConnectPromise = newMultiChannelStream.connect(); reconnectedHostStream.resolve(clientStream); await serverConnectPromise; reconnectedClientMultiChannelStream.resolve(newMultiChannelStream); // Add port to the tunnel host and wait for it on the client await testConnection.addPortOnHostAndValidateOnClient(9995); // Clean up await testConnection.dispose(); } @test async connectRelayClientToHostAndReconnectClient() { const testConnection = await this.startHostWithClientAndAddPort(); const { relayHost, relayClient, clientMultiChannelStream, clientStream } = testConnection; // Disconnect the tunnel client. It'll eventually reconnect. clientStream?.channel.dispose(); // Add port to the tunnel host and wait for it on the client await testConnection.addPortOnHostAndValidateOnClient(9995); assert.strictEqual(clientMultiChannelStream.streamsOpened, 2); // Clean up await relayClient.dispose(); await relayHost.dispose(); } @test async connectRelayClientToHostAndFailToReconnectClient() { const testConnection = await this.startHostWithClientAndAddPort(); const { relayClient, clientStream } = testConnection; // Wait for client disconnection and closed SSH session const disconnected = this.connectionStatusChanged( relayClient, ConnectionStatus.Connecting, ConnectionStatus.Disconnected, ); const sshSessionClosed = new Promise((resolve) => { let disposable: Disposable | undefined = relayClient.sshSessionClosedEvent((e) => { disposable?.dispose(); resolve(); }); }); // Prepare the error that will thrown on reconnection attempt const error = new RelayConnectionError('error.tunnelPortNotFound', { errorType: RelayErrorType.TunnelPortNotFound, statusCode: 404, }); relayClient.streamFactory = MockTunnelRelayStreamFactory.throwing(error); // Disconnect the tunnel client. It won't reconnect when it hits 404. clientStream?.channel.dispose(); await sshSessionClosed; assert.strictEqual(await disconnected, error); await testConnection.dispose(); } private async startHostWithClientAndAddPort(): Promise { const managementClient = new MockTunnelManagementClient(); managementClient.hostRelayUri = this.mockHostRelayUri; managementClient.clientRelayUri = this.mockClientRelayUri; // Create and start tunnel host. const tunnel = this.createRelayTunnel([], true); // Hosting a tunnel adds the endpoint await managementClient.createTunnel(tunnel); const relayHost = new TunnelRelayTunnelHost(managementClient); let clientMultiChannelStream = await this.connectRelayHost(relayHost, tunnel); assert.strictEqual(clientMultiChannelStream.streamsOpened, 0); let clientStream: SshStream | undefined; const relayClient = new TestTunnelRelayTunnelClient(); relayClient.streamFactory = MockTunnelRelayStreamFactory.fromMultiChannelStream( clientMultiChannelStream, TunnelRelayTunnelClient.webSocketSubProtocol, (s) => { clientStream = s; }, ); await relayClient.connect(tunnel); assert.strictEqual(clientMultiChannelStream.streamsOpened, 1); const result: TestConnection = { relayHost, relayClient, managementClient, clientMultiChannelStream, clientStream, dispose: async () => { await relayClient.dispose(); await relayHost.dispose(); }, addPortOnHostAndValidateOnClient: async (portNumber: number) => { const disposables: Disposable[] = []; let clientPortAdded = new Promise((resolve, reject) => { relayClient.forwardedPorts?.onPortAdded((e) => resolve(e.port.remotePort), disposables); }); await managementClient.createTunnelPort(relayHost.tunnel!, { portNumber }); await relayHost.refreshPorts(); try { assert.strictEqual(await clientPortAdded, portNumber); } finally { disposables.forEach((d) => d.dispose()); } } }; // Add port to the tunnel host and wait for it on the client await result.addPortOnHostAndValidateOnClient(9985); return result; } private async connectionStatusChanged( connection: TunnelConnection, ...expectedStatus: ConnectionStatus[] ): Promise { return await new Promise((resolve, reject) => { connection.connectionStatusChanged((e) => { if (e.status === expectedStatus[0]) { if (expectedStatus.length > 1) { expectedStatus.shift(); } else { resolve(e.disconnectError); } } }); }); } } dev-tunnels-0.0.25/ts/test/tunnels-test/tunnelManagementTests.ts000066400000000000000000000130721450757157500250340ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import * as assert from 'assert'; import axios, { AxiosPromise, AxiosRequestConfig, Method } from 'axios'; import * as https from 'https'; import { suite, test, slow, timeout } from '@testdeck/mocha'; import { TunnelManagementHttpClient } from '@microsoft/dev-tunnels-management'; import { Tunnel } from '@microsoft/dev-tunnels-contracts'; @suite @slow(3000) @timeout(10000) export class TunnelManagementTests { private readonly managementClient: TunnelManagementHttpClient; public constructor() { this.managementClient = new TunnelManagementHttpClient( 'test/0.0.0', undefined, 'http://global.tunnels.test.api.visualstudio.com'); (this.managementClient).request = this.mockRequest.bind(this); } private lastRequest?: { method: Method, uri: string, data: any, config: AxiosRequestConfig, }; private nextResponse?: any; private async mockRequest( method: Method, uri: string, data: any, config: AxiosRequestConfig, ): Promise { this.lastRequest = { method, uri, data, config }; return Promise.resolve(this.nextResponse as TResponse); } @test public async listTunnelsInCluster() { this.nextResponse = []; const testClusterId = 'test'; await this.managementClient.listTunnels(testClusterId); assert(this.lastRequest && this.lastRequest.uri); assert(this.lastRequest.uri.startsWith('http://' + testClusterId + '.')); assert(!this.lastRequest.uri.includes('global=true')); } @test public async listTunnelsGlobal() { this.nextResponse = []; await this.managementClient.listTunnels(); assert(this.lastRequest && this.lastRequest.uri); assert(this.lastRequest.uri.startsWith('http://global.')); assert(this.lastRequest.uri.includes('global=true')); } @test public async listTunnelsIncludePorts() { this.nextResponse = []; await this.managementClient.listTunnels(undefined, undefined, { includePorts: true }); assert(this.lastRequest && this.lastRequest.uri); assert(this.lastRequest.uri.startsWith('http://global.')); assert(this.lastRequest.uri.includes('includePorts=true&global=true')); } @test public async listUserLimits() { this.nextResponse = []; await this.managementClient.listUserLimits(); assert(this.lastRequest && this.lastRequest.uri); assert.equal(this.lastRequest.method, 'GET'); assert(this.lastRequest.uri.endsWith('/api/v1/userlimits')); } @test public async configDoesNotContainHttpsAgentAndAdapter() { this.nextResponse = []; await this.managementClient.listUserLimits(); assert(this.lastRequest); assert(this.lastRequest.config.httpsAgent === undefined); assert(this.lastRequest.config.adapter === undefined); } @test public async configContainsHttpsAgentAndAdapter() { // Create a mock https agent const httpsAgent = new https.Agent({ rejectUnauthorized: true, keepAlive: true, }); // Create a mock axios adapter interface AxiosAdapter { (config: AxiosRequestConfig): AxiosPromise; } class AxiosAdapter implements AxiosAdapter { constructor(private client: any, private auth: any) { } } const axiosAdapter = new AxiosAdapter(axios, { auth: { username: 'test', password: 'test' } }); // Create a management client with a mock https agent and adapter const managementClient = new TunnelManagementHttpClient( 'test/0.0.0', undefined, 'http://global.tunnels.test.api.visualstudio.com', httpsAgent, axiosAdapter); (managementClient).request = this.mockRequest.bind(this); this.nextResponse = []; await managementClient.listUserLimits(); assert(this.lastRequest); // Assert that the https agent and adapter are the same as the ones we passed into the constructor assert(this.lastRequest.config.httpsAgent === httpsAgent); assert(this.lastRequest.config.httpsAgent !== new https.Agent({ rejectUnauthorized: true, keepAlive: true, })); assert(this.lastRequest.config.adapter === axiosAdapter); assert(this.lastRequest.config.adapter !== new AxiosAdapter(axios, { auth: { username: 'test', password: 'test' } })) } @test public async preserveAccessTokens() { const requestTunnel = { tunnelId: 'tunnelid', clusterId: 'clusterId', accessTokens: { 'manage': 'manage-token-1', 'connect': 'connect-token-1', }, }; this.nextResponse = { tunnelId: 'tunnelid', clusterId: 'clusterId', accessTokens: { 'manage': 'manage-token-2', 'host': 'host-token-2', }, }; const resultTunnel = await this.managementClient.getTunnel(requestTunnel); assert(this.lastRequest && this.lastRequest.uri); assert(resultTunnel); assert(resultTunnel.accessTokens); assert.strictEqual(resultTunnel.accessTokens['connect'], 'connect-token-1'); // preserved assert.strictEqual(resultTunnel.accessTokens['host'], 'host-token-2'); // added assert.strictEqual(resultTunnel.accessTokens['manage'], 'manage-token-2'); // updated } } dev-tunnels-0.0.25/ts/test/tunnels-test/userInfo.ts000066400000000000000000000007001450757157500222730ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. export class UserInfo { public aadProvider?: string = 'AAD'; public githubProvider?: string = 'GitHub'; public username?: string; public provider?: string; public accessToken?: string; public tokenStatus?: authenticationTokenStatus; public tokenExpiration?: Date; } export enum authenticationTokenStatus { None, Valid, Expired, } dev-tunnels-0.0.25/ts/test/tunnels-test/userManager.ts000066400000000000000000000006551450757157500227630ustar00rootroot00000000000000// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { CommonOptions } from 'child_process'; import { LoginOptions } from './mocks/mockUserManager'; import { UserInfo } from './userInfo'; export interface UserManager { getCurrentUser(): Promise; login(options: LoginOptions, deviceCodeCallback: Function): Promise; getCurrentUser(options: any): Promise; } dev-tunnels-0.0.25/ts/tsconfig.json000066400000000000000000000014341450757157500172210ustar00rootroot00000000000000{ "compilerOptions": { "composite": true, "module": "commonjs", "target": "es2017", "lib": [ "es2017", "dom" ], "declaration": true, "declarationMap": true, "sourceMap": true, "strict": true, "stripInternal": true, "resolveJsonModule": true, "forceConsistentCasingInFileNames": true, "experimentalDecorators": true, "baseUrl": ".", "paths": { "@microsoft/dev-tunnels-contracts": [ "./src/contracts" ], "@microsoft/dev-tunnels-management": [ "./src/management" ], "@microsoft/dev-tunnels-connections": [ "./src/connections" ] } }, "include": [], "references": [ { "path": "./src/contracts" }, { "path": "./src/management" }, { "path": "./src/connections" }, { "path": "./test/tunnels-test" } ] } dev-tunnels-0.0.25/version.json000066400000000000000000000022061450757157500164420ustar00rootroot00000000000000{ "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", // Specifies the Major and Minor product version. The build number is automatically appended // to this as the 3rd part of the product version. Increment the Major product version for // major product milestones. Optionally increment the Minor product version for minor milestones. "version": "1.0", // Remove this when we update version number to start from 0 "versionHeightOffset": 7200, "publicReleaseRefSpec": [ "^refs/heads/main$", // we release out of main "^refs/heads/v\\d+(?:.\\d+)?$", // we also release out of vNN branches "^refs/heads/releases/.+$" // weekly release branches ], "cloudBuild": { "setVersionVariables": true, "buildNumber": { "enabled": true, "includeCommitId": { "when": "nonPublicReleaseOnly", // Tell NB.GV to create a build revision from the commit id. // Using buildMetadata inserts "+commitId", and the "+" character is invalid in the docker image tag "where": "fourthVersionComponent" } } } }