├── .envrc ├── .eslintrc.json ├── .github ├── renovate.json5 └── workflows │ ├── on-main.yml │ ├── on-pull-request.yml │ ├── on-version-tag.yml │ ├── package.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yaml ├── .husky └── pre-commit ├── .nvmrc ├── .prettierignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── .yarnrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── flake.lock ├── flake.nix ├── go.mod ├── go.sum ├── node.rev ├── package.json ├── postcss.config.js ├── prettier.config.js ├── profiles ├── offline.json └── snapshot.mjs ├── resources ├── dark │ ├── offline.svg │ ├── online.svg │ └── terminal.svg ├── images │ ├── icon_256x256.png │ └── mark.svg ├── light │ ├── offline.svg │ ├── online.svg │ └── terminal.svg ├── readme │ ├── funnel-palette-demo.gif │ ├── funnel-panel-demo.gif │ ├── funnel-port-disco-demo.gif │ ├── header.png │ ├── machine-explorer.png │ ├── remote-explorer.png │ └── terminal.png └── walkthrough │ ├── about.md │ ├── enable-funnel.md │ ├── enable-https.md │ ├── install │ ├── linux.md │ ├── macos.md │ └── windows.md │ └── share-port.md ├── src ├── config-manager.test.ts ├── config-manager.ts ├── constants.ts ├── extension.ts ├── filesystem-provider-sftp.ts ├── filesystem-provider-ssh.ts ├── filesystem-provider-timing.ts ├── filesystem-provider.ts ├── get-nonce.ts ├── logger.ts ├── node-explorer-provider.ts ├── serve-panel-provider.ts ├── sftp.ts ├── ssh-connection-manager.ts ├── ssh.ts ├── tailscale │ ├── analytics.ts │ ├── cli.ts │ ├── error.ts │ └── index.ts ├── types.ts ├── utils │ ├── error.ts │ ├── host.ts │ ├── index.ts │ ├── sshconfig.ts │ ├── string.test.ts │ ├── string.ts │ ├── uri.test.ts │ ├── uri.ts │ └── url.ts ├── vscode-api.ts └── webviews │ └── serve-panel │ ├── app.tsx │ ├── components │ ├── error.tsx │ ├── path-input.tsx │ ├── port-input.tsx │ └── tooltip.tsx │ ├── data.tsx │ ├── index.css │ ├── index.tsx │ ├── simple-view.tsx │ └── window.d.ts ├── tailwind.config.cjs ├── tool ├── go ├── node └── yarn ├── tsconfig.json ├── tsrelay ├── dist │ ├── artifacts.json │ ├── config.yaml │ ├── metadata.json │ ├── vscode-tailscale_darwin_amd64_v1 │ │ └── vscode-tailscale │ ├── vscode-tailscale_darwin_arm64 │ │ └── vscode-tailscale │ ├── vscode-tailscale_linux_386 │ │ └── vscode-tailscale │ ├── vscode-tailscale_linux_amd64_v1 │ │ └── vscode-tailscale │ ├── vscode-tailscale_linux_arm64 │ │ └── vscode-tailscale │ ├── vscode-tailscale_windows_386 │ │ └── vscode-tailscale.exe │ ├── vscode-tailscale_windows_amd64_v1 │ │ └── vscode-tailscale.exe │ └── vscode-tailscale_windows_arm64 │ │ └── vscode-tailscale.exe ├── handler │ ├── auth_middleware.go │ ├── create_serve.go │ ├── delete_serve.go │ ├── error.go │ ├── get_configs.go │ ├── get_peers.go │ ├── get_serve.go │ ├── handler.go │ ├── local_client.go │ ├── portdisco.go │ ├── portdisco_test.go │ ├── set_funnel.go │ └── set_serve_config.go ├── logger │ └── logger.go └── main.go ├── vitest.config.ts ├── vsc-extension-quickstart.md ├── webpack.config.mjs ├── yarn.lock └── yarn.rev /.envrc: -------------------------------------------------------------------------------- 1 | if has nix; then 2 | use flake 3 | fi 4 | 5 | PATH_add tool 6 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "env": { 9 | "node": true 10 | }, 11 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 12 | "plugins": ["@typescript-eslint", "unicorn"], 13 | "rules": { 14 | "@typescript-eslint/no-unused-vars": "off", 15 | "unicorn/filename-case": [ 16 | "error", 17 | { 18 | "case": "kebabCase" 19 | } 20 | ] 21 | }, 22 | "ignorePatterns": ["out", "dist", "**/*.d.ts"], 23 | "overrides": [ 24 | { 25 | "files": ["src/webviews/**/*.ts", "src/webviews/**/*.tsx"], 26 | "extends": ["plugin:react-hooks/recommended"], 27 | "plugins": ["react-hooks"], 28 | "rules": { 29 | "react-hooks/rules-of-hooks": "error", 30 | "react-hooks/exhaustive-deps": "warn" 31 | } 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: 'https://docs.renovatebot.com/renovate-schema.json', 3 | baseBranches: ['main'], 4 | enabledManagers: ['npm'], 5 | extends: ['config:base'], 6 | prConcurrentLimit: 0, 7 | prHourlyLimit: 0, 8 | rangeStrategy: 'bump', 9 | timezone: 'America/Los_Angeles', 10 | schedule: ['before 8am on monday'], 11 | packageRules: [ 12 | { 13 | groupName: 'react', 14 | matchPackagePatterns: ['^react', '^@types/react', '.*react$'], 15 | }, 16 | { 17 | groupName: 'eslint', 18 | matchPackagePatterns: ['^eslint', '^@typescript-eslint', 'prettier'], 19 | }, 20 | { 21 | groupName: 'typescript', 22 | matchPackagePatterns: ['^typescript', '^@typescript'], 23 | }, 24 | { 25 | groupName: 'tailwindcss', 26 | matchPackagePatterns: ['^tailwindcss', '^@tailwindcss', '.*tailwindcss$'], 27 | }, 28 | { 29 | groupName: 'webpack', 30 | matchPackagePatterns: ['^webpack', '-loader$', 'postcss'], 31 | }, 32 | { 33 | groupName: 'vscode', 34 | matchPackagePatterns: ['^@vscode'], 35 | }, 36 | 37 | // ignored 38 | { 39 | matchDepTypes: ['engines'], 40 | matchPackageNames: ['vscode'], 41 | enabled: false, 42 | }, 43 | { 44 | matchDepTypes: ['devDependencies'], 45 | matchPackageNames: ['@types/vscode'], 46 | enabled: false, 47 | }, 48 | { 49 | matchDepTypes: ['devDependencies'], 50 | matchPackageNames: ['@types/node'], 51 | enabled: false, 52 | }, 53 | ], 54 | } 55 | -------------------------------------------------------------------------------- /.github/workflows/on-main.yml: -------------------------------------------------------------------------------- 1 | name: on-main 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | test: 9 | uses: './.github/workflows/test.yml' 10 | 11 | pre-release: 12 | name: 'Pre Release' 13 | uses: ./.github/workflows/release.yml 14 | needs: test 15 | 16 | with: 17 | prerelease: true 18 | title: 'Development' 19 | release_name: 'latest' 20 | -------------------------------------------------------------------------------- /.github/workflows/on-pull-request.yml: -------------------------------------------------------------------------------- 1 | name: on-pull-request 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | test: 9 | uses: './.github/workflows/test.yml' 10 | 11 | package: 12 | uses: ./.github/workflows/package.yml 13 | -------------------------------------------------------------------------------- /.github/workflows/on-version-tag.yml: -------------------------------------------------------------------------------- 1 | name: 'tagged-release' 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | name: 'Release' 11 | uses: ./.github/workflows/release.yml 12 | 13 | with: 14 | prerelease: false 15 | -------------------------------------------------------------------------------- /.github/workflows/package.yml: -------------------------------------------------------------------------------- 1 | name: 'package' 2 | 3 | on: 4 | workflow_dispatch: 5 | workflow_call: 6 | 7 | jobs: 8 | package: 9 | name: 'Package' 10 | runs-on: 'ubuntu-latest' 11 | timeout-minutes: 10 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | - name: Fetch tags 18 | run: git fetch --force --tags 19 | - name: Capture Yarn cache path 20 | id: yarn-cache-folder 21 | run: echo "YARN_CACHE_FOLDER=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT 22 | - name: Yarn cache 23 | uses: actions/cache@v3 24 | id: yarn-cache 25 | with: 26 | path: ${{ steps.yarn-cache-folder.outputs.YARN_CACHE_FOLDER }} 27 | key: yarn-cache 28 | - name: Install dependencies 29 | run: tool/yarn install --frozen-lockfile 30 | - name: Set up Go 31 | uses: actions/setup-go@v4 32 | with: 33 | go-version: '1.21.x' 34 | - name: Capture version 35 | id: version 36 | run: npm run env | grep npm_package_version >> $GITHUB_OUTPUT 37 | - name: Package 38 | run: tool/yarn package 39 | env: 40 | GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.npm_package_version }} 41 | - name: Upload vsix artifact 42 | uses: actions/upload-artifact@v3 43 | with: 44 | name: vsix 45 | path: '*.vsix' 46 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: 'release' 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | prerelease: 7 | description: 'Whether or not this is a pre-release' 8 | required: true 9 | default: false 10 | type: boolean 11 | 12 | title: 13 | description: 'The title of the release' 14 | required: false 15 | type: string 16 | 17 | release_name: 18 | description: 'The name of the release, uses the tag if not specified' 19 | required: false 20 | type: string 21 | 22 | workflow_call: 23 | inputs: 24 | prerelease: 25 | description: 'Whether or not this is a pre-release' 26 | required: true 27 | default: false 28 | type: boolean 29 | 30 | title: 31 | description: 'The title of the release' 32 | required: false 33 | type: string 34 | 35 | release_name: 36 | description: 'The name of the release, uses the tag if not specified' 37 | required: false 38 | type: string 39 | jobs: 40 | package: 41 | name: 'Package' 42 | uses: ./.github/workflows/package.yml 43 | 44 | release: 45 | name: 'Release' 46 | runs-on: 'ubuntu-latest' 47 | needs: 'package' 48 | timeout-minutes: 5 49 | 50 | steps: 51 | - uses: actions/checkout@v3 52 | - name: Download vsix artifact 53 | uses: actions/download-artifact@v3 54 | with: 55 | name: vsix 56 | - name: 'Release' 57 | uses: 'marvinpinto/action-automatic-releases@d68defdd11f9dcc7f52f35c1b7c236ee7513bcc1' 58 | with: 59 | repo_token: '${{ secrets.GITHUB_TOKEN }}' 60 | automatic_release_tag: ${{ inputs.release_name }} 61 | prerelease: ${{ inputs.prerelease }} 62 | title: ${{ inputs.title }} 63 | files: | 64 | *.vsix 65 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: 'test' 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | test: 8 | name: 'Test' 9 | strategy: 10 | matrix: 11 | # os: [macos-latest, ubuntu-latest, windows-latest] 12 | os: [ubuntu-latest] 13 | runs-on: ${{ matrix.os }} 14 | timeout-minutes: 10 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Capture Yarn cache path 18 | id: yarn-cache-folder 19 | run: echo "YARN_CACHE_FOLDER=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT 20 | - name: Yarn cache 21 | uses: actions/cache@v3 22 | id: yarn-cache 23 | if: runner.os != 'Windows' 24 | with: 25 | path: ${{ steps.yarn-cache-folder.outputs.YARN_CACHE_FOLDER }} 26 | key: yarn-cache 27 | - name: Install dependencies 28 | run: tool/yarn install --frozen-lockfile --prefer-offline 29 | - name: Unit tests 30 | run: tool/yarn test 31 | - name: Lint 32 | run: tool/yarn lint 33 | - name: Typescript 34 | run: tool/yarn tsc --noEmit 35 | - name: Prettier 36 | run: tool/yarn prettier --check . 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # production 5 | out 6 | dist 7 | 8 | # vscode 9 | .vscode-test/ 10 | *.vsix 11 | 12 | # misc 13 | .DS_Store 14 | status.json 15 | 16 | # tailscale tooling 17 | /.redo 18 | yarn.cmd 19 | node.cmd 20 | 21 | # nix direnv 22 | /.direnv 23 | 24 | bin/ 25 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | builds: 5 | - env: 6 | - GOWORK=off 7 | - CGO_ENABLED=0 8 | goos: 9 | - linux 10 | - windows 11 | - darwin 12 | goarch: 13 | - arm64 14 | - amd64 15 | main: ./tsrelay 16 | dist: ./bin 17 | archives: 18 | - format: tar.gz 19 | # this name template makes the OS and Arch compatible with the results of uname. 20 | name_template: >- 21 | {{ .ProjectName }}_ 22 | {{- title .Os }}_ 23 | {{- if eq .Arch "amd64" }}x86_64 24 | {{- else if eq .Arch "386" }}i386 25 | {{- else }}{{ .Arch }}{{ end }} 26 | {{- if .Arm }}v{{ .Arm }}{{ end }} 27 | # use zip for windows archives 28 | format_overrides: 29 | - goos: windows 30 | format: zip 31 | checksum: 32 | name_template: 'checksums.txt' 33 | snapshot: 34 | name_template: '{{ incpatch .Version }}-next' 35 | changelog: 36 | sort: asc 37 | filters: 38 | exclude: 39 | - '^docs:' 40 | - '^test:' 41 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | tool/yarn precommit 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.13.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .vscode-test 2 | dist/* 3 | out/* -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint", 6 | "amodio.tsl-problem-matcher", 7 | "bradlc.vscode-tailwindcss" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": ["--extensionDevelopmentPath=${workspaceFolder}", "--disable-extensions"], 13 | "env": { 14 | "NODE_ENV": "development" 15 | }, 16 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 17 | "preLaunchTask": "${defaultBuildTask}" 18 | }, 19 | { 20 | "type": "node", 21 | "request": "launch", 22 | "name": "Debug Current Test File", 23 | "autoAttachChildProcesses": true, 24 | "skipFiles": ["/**", "**/node_modules/**"], 25 | "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs", 26 | "args": ["run", "${relativeFile}"], 27 | "smartStep": true, 28 | "console": "integratedTerminal" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false, // set this to true to hide the "out" folder with the compiled JS files 5 | "dist": false // set this to true to hide the "dist" folder with the compiled JS files 6 | }, 7 | "search.exclude": { 8 | "out": true, // set this to false to include "out" folder in search results 9 | "dist": true // set this to false to include "dist" folder in search results 10 | }, 11 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 12 | "typescript.tsc.autoDetect": "off", 13 | 14 | // Support for tailwindCSS 15 | "files.associations": { 16 | "*.css": "tailwindcss" 17 | }, 18 | 19 | "editor.quickSuggestions": { 20 | "strings": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "shell", 8 | "label": "watch", 9 | "command": "./tool/yarn run watch", 10 | "problemMatcher": "$ts-webpack-watch", 11 | "isBackground": true, 12 | "presentation": { 13 | "reveal": "never", 14 | "group": "watchers" 15 | }, 16 | "group": { 17 | "kind": "build", 18 | "isDefault": true 19 | } 20 | }, 21 | { 22 | "type": "shell", 23 | "label": "watch-tests", 24 | "command": "./tool/yarn run watch-tests", 25 | "problemMatcher": "$tsc-watch", 26 | "isBackground": true, 27 | "presentation": { 28 | "reveal": "never", 29 | "group": "watchers" 30 | }, 31 | "group": "build" 32 | }, 33 | { 34 | "label": "tasks: watch-tests", 35 | "dependsOn": ["npm: watch", "npm: watch-tests"], 36 | "problemMatcher": [] 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | * 2 | */** 3 | **/.DS_Store 4 | !package.json 5 | !README.md 6 | !CHANGELOG.md 7 | !LICENSE.txt 8 | !resources/**/* 9 | !bin/**/vscode-tailscale* 10 | !dist/*.js 11 | !dist/*.ttf 12 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | --ignore-engines true -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to an [Odd-Even Versioning](https://en.wikipedia.org/wiki/Software_versioning#Odd-numbered_versions_for_development_releases) scheme. Odd-numbered versions are used for development and pre-release updates, while even-numbered versions are used for stable or public releases. 6 | 7 | ## v1.0.0 - October 31, 2023 8 | 9 | - The Tailscale extension for VS Code is generally available (GA) 10 | 11 | ## v0.8.0 - October 31, 2023 12 | 13 | - Symlink support in File explorer 14 | - Fixed issue with port discovery not working on Linux 15 | 16 | ## v0.6.4 - September 27, 2023 17 | 18 | - Using "Attach VS Code" requires the user to be defined in the SSH configuration. We will now prompt to sync SSH configuration when using that feature. 19 | - Allow setting a sub-directory as a root using tilde (example: ~/foo) 20 | - Add "tailscale.fileExplorer.showDotFiles" to control if dot-files (example: .foo) are shown in the File Explorer. 21 | - Support for Tailscale client version 1.50.0 22 | - Auto-refresh Node Explorer periodically for updates to your tailnet. The polling period can be configured (and polling can be disabled) via "tailscale.nodeExplorer.refreshInterval". 23 | 24 | ## v0.6.2 - August 23, 2023 25 | 26 | - Allow for opening and editing of symlinks 27 | - Provide context menu to change SSH user or home directory on the File Explorer node 28 | 29 | ## v0.6.0 - August 11, 2023 30 | 31 | New: View and interact with machines on your tailnet. Powered by [Tailscale SSH](https://tailscale.com/tailscale-ssh/), you can remotely manage files, open terminal sessions, or attach remote VS Code sessions. 32 | 33 | ## v0.4.4 - June 29, 2023 34 | 35 | An update providing a fix for users running on Flatpak while reducing the required VS Code version to 1.74.0. 36 | 37 | ### Changed 38 | 39 | - package.json: change "engines"."vscode" to "^1.74.0" (#89) 40 | - Only run in the UI, not on a remote (#58) 41 | - Replace tailscale binary with tailscaled unix socket (#83) 42 | - Upgrade dependencies: vscode (#48), @types/node (#49), glob (#51), eslint (#65), webpack (#63), typescript (#69, #78), react (#68), @types/react (#74), swr (#72), 43 | 44 | ### Fixed 45 | 46 | - Run flatpak-spawn when pkexec is needed (#86) 47 | - Only add menu items to serve view (#77) 48 | 49 | ## v0.4.3 - June 21, 2023 50 | 51 | ### Fixed 52 | 53 | - Use sudo-prompt to re-run tsrelay in Linux (#64) 54 | - src/tailscale/cli: fix go path for development mode (#67) 55 | - Return manual resolution when access is denied to LocalBackend (#60) 56 | - Output server details as json (#37) 57 | 58 | ### Changed 59 | 60 | - Upgrade dependencies: react, typescript, webpack, eslint, prettier, postcss, tailwindcss, lint-staged (#38, #39, #40, #41, #43, #44, #46, #47, #50, #53) 61 | 62 | ## v0.4.2 - June 13, 2023 63 | 64 | ### Added 65 | 66 | - serve/simple: Notice for Linux users (#62) 67 | 68 | ## v0.4.1 - June 13, 2023 69 | 70 | ### Added 71 | 72 | - Show error message for expired node key (#1) 73 | - Provide information on service underlying a proxy (#2) 74 | - readme: Added notice for Linux users (#61) 75 | 76 | ### Changed 77 | 78 | - portdisco: switch to upstream portlist package (#10) 79 | - Upgrade dependencies: webpack, webpack-cli, webpack-dev-server, css-loader, postcss-loader, style-loader, ts-loader (#34) 80 | 81 | Initial public release 82 | 83 | ## v0.4.0 - May 31, 2023 84 | 85 | ### Added 86 | 87 | - Simple view for adding a Funnel 88 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Tailscale VS Code Extension 2 | 3 | ## Running the extension 4 | 5 | Inside the editor, press F5. This will compile and run the extension in a new Extension Development Host window. 6 | Alternatively, from "Debug & Run" in the "Activity Bar", click the play icon for "Start Debugging" 7 | 8 | ## Building the extension 9 | 10 | A vsix build can be produced by running `./tool/yarn vsce package --allow-star-activation` 11 | 12 | ## Development tools 13 | 14 | Following the [Tailscale OSS](https://github.com/tailscale/tailscale) repository, we use a `./tool` directory to manage tool dependencies. Versions are pinned via `*.rev` files in the projects' root and installed on demand. 15 | 16 | Flakes are provided for Nix users, with `nix develop` for the environment. 17 | 18 | The following tools are available: 19 | 20 | - `./tool/Go` - [Go](https://go.dev/) for tsrelay backend 21 | - `./tool/node` - [Node](https://nodejs.org/) for JavaScript tooling 22 | - `./tool/yarn` - [Yarn](https://yarnpkg.com/) package manager 23 | 24 | If available, [direnv](https://direnv.net/) will place these tools in your PATH per our `.envrc` config. Installation instructions for direnv are available [here](https://direnv.net/docs/installation.html). 25 | 26 | ## Webview 27 | 28 | ### Icons 29 | 30 | Our VS Code extension uses the VS Code Codicons icon font provided by Microsoft, which can be accessed through the following GitHub repository: [microsoft/vscode-codicons](https://github.com/microsoft/vscode-codicons). The icon font is generated using the Fantasticon tool and includes a wide range of icons suitable for different purposes. 31 | 32 | To search for specific icons, you can use the icon search tool available at https://microsoft.github.io/vscode-codicons/dist/codicon.html. Once you have found the desired icon, you can include it in your HTML code using the following syntax: 33 | 34 | ```html 35 |
36 | ``` 37 | 38 | Alternatively, you can use an SVG sprite to include the icon in your code, as shown below: 39 | 40 | ```html 41 | 42 | 43 | 44 | ``` 45 | 46 | Note that the `codicon-add` class in the first example corresponds to the `add` icon in the second example, which is referenced by the `#add` attribute in the SVG `` element. 47 | 48 | ### Colors 49 | 50 | To ensure that our VS Code extension is compatible with different themes, we recommend using CSS variables provided by VS Code whenever possible. These variables are defined by the VS Code theme and can be accessed by our extension to ensure consistent styling across different themes. 51 | 52 | To discover the available CSS variables, you can access the Developer Tools (more information is available in the [debugging](#debugging-webviews) section) or use the Spectrum plugin, which provides a visual editor for modifying VS Code themes. The Spectrum plugin is available from the VS Code Marketplace at https://marketplace.visualstudio.com/items?itemName=GitHubOCTO.spectrum. 53 | 54 | To use a CSS variable in your code, you can include it in a CSS property value using the `var()` function, as shown below: 55 | 56 | ```css 57 | text-[var(--vscode-button-background)] 58 | ``` 59 | 60 | In this example, the `--vscode-button-background` variable is used to set the background color of a text element, but you can use other variables for different properties and purposes. Using CSS variables in this way ensures that your extension will be compatible with different themes and will adapt to the user's preferences. 61 | 62 | ### Debugging webviews 63 | 64 | To debug webviews in our VS Code extension, you can access the Developer Tools by selecting "Developer: Toggle Developer Tools" from the Command Palette. The Command Palette can be opened by clicking on the icon in the top-right corner of the VS Code window or by using the keyboard shortcut Ctrl+Shift+P on Windows or Cmd+Shift+P on Mac. Once the Developer Tools are open, you can inspect and debug the webviews using the same tools available for debugging web pages in a web browser. 65 | 66 | ### VS Code Webview UI Toolkit 67 | 68 | We make use of [Webview UI Toolkit for Visual Studio Code](https://www.npmjs.com/package/@vscode/webview-ui-toolkit), specifically for its [React Components](https://github.com/microsoft/vscode-webview-ui-toolkit/tree/main/src/react)](https://github.com/microsoft/vscode-webview-ui-toolkit/tree/main/src/react) which are wrappers around the components. These components follow the design language of VS Code to maintain a consistent look and feel. 69 | 70 | ## Release Process 71 | 72 | #### To make a new minor release. (e.g., `0.2` ⇒ `0.4`) 73 | 74 | From the `main` branch: 75 | 76 | ``` 77 | $ git checkout -b release-branch/v0.4 78 | ``` 79 | 80 | #### To make a new patch for an existing release (e.g., `0.2.0` ⇒ `0.2.1`) 81 | 82 | ``` 83 | git checkout release-branch/0.2 84 | 85 | # cherry-pick to add patches to the release 86 | $ git cherry-pick -x 87 | ``` 88 | 89 | ### Update CHANGELOG.md 90 | 91 | Using the diff between the latest tag, and the release branch after cherry picking 92 | 93 | ``` 94 | git log --pretty=oneline v0.2.1..release-branch/v0.2 95 | ``` 96 | 97 | Group changes by `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, and `Security` 98 | Open a pull-request for the changes and cherry-pick into the release branch 99 | 100 | ### Update the version 101 | 102 | ``` 103 | $ npm version --no-git-tag-version 0.2.1 104 | $ git add package.json && git commit -sm 'version: v0.2.1' 105 | $ git tag -am "Release 0.2.1" "v0.2.1" 106 | ``` 107 | 108 | ### Create or update an existing release branch 109 | 110 | ### Push the release branch and tag 111 | 112 | ``` 113 | $ git push -u origin HEAD 114 | $ git push origin v0.2.1 115 | ``` 116 | 117 | ### Upload to marketplace 118 | 119 | https://marketplace.visualstudio.com/manage 120 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) Tailscale Inc & AUTHORS. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Twitter Follow 2 | Mastodon Follow 3 | YouTube Channel Views 4 | Tailscale's stars 5 | 6 |

7 |
8 | Tailscale Logo 9 |

10 | 11 | > Tailscale is a free and open source service, based on WireGuard®, that helps users build no-hassle virtual private networks. With a Tailscale network (tailnet), you can securely access services and devices on that tailnet from anywhere in the world. Tailnets can include anything from a Digital Ocean droplet to a Raspberry Pi, home security camera, or even a Steam Deck. You can share nodes with friends or co-workers, or even expose ports to the public internet with Tailscale Funnel. 12 | 13 | # Tailscale for Visual Studio Code 14 | 15 | The Tailscale extension for VS Code brings no-hassle, secure networking alongside your code. With Tailscale Funnel you can share anything from a device or node in your Tailscale network (tailnet) with anyone on the internet. 16 | 17 | ## Features 18 | 19 | ### Explore remote machines 20 | 21 | View and interact with machines on your tailnet within VS Code. Powered by [Tailscale SSH](https://tailscale.com/tailscale-ssh/), you can remotely manage files, open terminal sessions, or attach remote VS Code sessions. 22 | 23 | ![VS Code with the Tailscale Machine Explorer expanded, showing a tailnet with a variety of services](https://raw.githubusercontent.com/tailscale-dev/vscode-tailscale/main/resources/readme/machine-explorer.png) 24 | 25 | #### Connect to a machine in your tailnet 26 | 27 | You can start a new terminal session or attach VS Code to a machine for remote development in one click. Hover over the machine you'd like to connect to, and click the VS Code Terminal Icon icon to start a terminal session, or the VS Code Remote Explorer Icon icon to attach the VS Code window to the machine. 28 | 29 | #### Edit files on a machine in your tailnet 30 | 31 | To view and edit files on a machine in your tailnet, expand the machine and click the **File explorer** item. You must have [enabled Tailscale SSH](https://tailscale.com/kb/1193/tailscale-ssh/#configure-tailscale-ssh) on the machine in order to use the file explorer. 32 | 33 | ### Expose a port over the internet 34 | 35 | Route traffic from the internet to a node in your tailnet to publicly share it with anyone, even if they aren’t using Taiscale. 36 | 37 | > _When you want something local to be available everywhere_ 38 | 39 | For example, you might want to receive a webhook from GitHub, share a local service with your coworker, or even host a personal blog or status page on your own computer. 40 | 41 | #### Tailscale Funnel panel 42 | 43 | ![A demo showing the VS Code extension's panel view used to serve local port 3000 (a Next.js boilerplate app) to a public URL with Tailscale Funnel](https://github.com/tailscale-dev/tailscale-dev/assets/40265/e9d1eac5-cf11-4075-bf8d-e8a377e2c9ed) 44 | 45 | 1. Open the panel. You can use the keyboard shortcut `CMD` + `J`. 46 | 2. Enter the local port you want to expose over the internet in the **port** field. 47 | 3. Click **Start** to begin serving this port over the internet. 48 | 49 | You can open the public URL in your default browser or copy it to your clipboard. 50 | 51 | #### With the Command Palette 52 | 53 | ![A demo showing the VS Code extension's palette view used to serve local port 3000 (a Next.js boilerplate app) to a public URL with Tailscale Funnel](https://github.com/tailscale-dev/tailscale-dev/assets/40265/97a177a3-3632-4dea-8a95-0ec3c631995d) 54 | 55 | 1. Open the command palette with the keyboard shortcut CMD + Shift + P. 56 | 2. Type **Tailscale** to view all of the extension’s commands. 57 | 3. Choose **Tailscale: Share port publicly using Funnel**. 58 | 4. Enter the local port number that you wish to share via Funnel. 59 | 60 | #### Port discovery 61 | 62 | ![A demo showing the VS Code extension's port discovery feature used to serve local port 3000 (an Astro boilerplate app) to a public URL with Tailscale Funnel](https://github.com/tailscale-dev/tailscale-dev/assets/40265/63b0a26b-018b-4158-a20d-22789bbca707) 63 | 64 | When you start a local server from VS Code, Tailscale will ask if you'd like to share that port over the internet with Funnel. 65 | 66 | This functionality can be disabled using the `tailscale.portDiscovery.enabled` option. 67 | 68 | ## How Funnel works 69 | 70 | | Internet accessible | Secure tunnel | 71 | | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | 72 | | Stable and public DNS records point to Tailscale ingress servers, so your hostnames are always predictable. | Tailscale ingress servers forward TLS-encrypted traffic to your private nodes using our secure, lightweight tunnels. | 73 | 74 | | End-to-end encrypted | Proxy or serve requests | 75 | | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | 76 | | Auto-provisioned TLS certificates terminate on your private nodes, so we never see your unencrypted traffic. | Serve local files, directories, or static content — or reverse proxy requests to a local web or TCP server. | 77 | 78 | For more information, visit the full documentation on [Tailscale Funnel](https://tailscale.com/kb/1223/tailscale-funnel/) or the [CLI](https://tailscale.com/kb/1242/tailscale-serve/) reference. 79 | 80 | ## Setup 81 | 82 | 1. [Install Tailscale](https://tailscale.com/download) and login or create a new account. 83 | 2. Install this extension in VS Code from the [Visual Studio Marketplace](vscode:extension/Tailscale.vscode-tailscale), or on open-source alternatives from the [Open VSX Registry](https://open-vsx.org/extension/tailscale/vscode-tailscale). 84 | 85 | > ⚠️ Important: You'll need to make sure that HTTPS certificates and Funnel are enabled in the Tailscale admin console. Refer to our [documentation](https://tailscale.com/kb/1223/tailscale-funnel/#setup) for more instructions. 86 | 87 | ## Commands 88 | 89 | - _Tailscale: Share port publicly using Funnel_ - expose a single port publicly over Funnel. 90 | - _Tailscale: Focus on Funnel View_ - open the Funnel panel view. 91 | 92 | ## Troubleshooting 93 | 94 | If the extension isn't working, we recommend following these steps to troubleshoot. 95 | 96 | 1. Check to ensure that Tailscale is signed in and active. On macOS and Windows, you can do this by clicking the Tailscale icon in your OS status bar. On Linux, run `tailscale status` in your CLI. 97 | - If you have signed in to multiple Tailscale accounts on your device, ensure that the correct account is active. 98 | 2. Ensure that your Tailnet access controls (ACLs) are [configured to allow Tailscale Funnel](https://tailscale.com/kb/1223/tailscale-funnel/#setup) on your device. 99 | 3. Ensure that [magicDNS and HTTPS Certificates are enabled](https://tailscale.com/kb/1153/enabling-https/) on your tailnet. 100 | 4. If you are running `tailscaled` in a non-default path, you can set its path via the `tailscale.socketPath` setting in VS Code. 101 | 5. If you are using VSCodium or another open-source Visual Studio Code alternative, ensure that the `Open Remote - SSH` addon is installed. 102 | 103 | ## Contribute 104 | 105 | We appreciate your help! For information on contributing to this extension, refer to the [CONTRIBUTING](CONTRIBUTING.md) document. 106 | 107 | ## Legal 108 | 109 | WireGuard is a registered trademark of Jason A. Donenfeld. 110 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1673796341, 6 | "narHash": "sha256-1kZi9OkukpNmOaPY7S5/+SlCDOuYnP3HkXHvNDyLQcc=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "6dccdc458512abce8d19f74195bb20fdb067df50", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "id": "nixpkgs", 14 | "ref": "nixos-unstable", 15 | "type": "indirect" 16 | } 17 | }, 18 | "root": { 19 | "inputs": { 20 | "nixpkgs": "nixpkgs", 21 | "utils": "utils" 22 | } 23 | }, 24 | "utils": { 25 | "locked": { 26 | "lastModified": 1667395993, 27 | "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=", 28 | "owner": "numtide", 29 | "repo": "flake-utils", 30 | "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f", 31 | "type": "github" 32 | }, 33 | "original": { 34 | "owner": "numtide", 35 | "repo": "flake-utils", 36 | "type": "github" 37 | } 38 | } 39 | }, 40 | "root": "root", 41 | "version": 7 42 | } 43 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "nixpkgs/nixos-unstable"; 4 | utils.url = "github:numtide/flake-utils"; 5 | }; 6 | 7 | outputs = { self, nixpkgs, utils }: 8 | utils.lib.eachDefaultSystem (system: 9 | let pkgs = nixpkgs.legacyPackages.${system}; in 10 | { 11 | devShells.default = pkgs.mkShell { 12 | buildInputs = with pkgs; [ 13 | nodejs-18_x 14 | yarn 15 | ]; 16 | shellHook = '' 17 | export PATH="$PATH":$(pwd)/node_modules/.bin 18 | ''; 19 | }; 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tailscale-dev/vscode-tailscale 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/go-chi/chi/v5 v5.0.10 7 | github.com/gorilla/websocket v1.5.0 8 | github.com/mitchellh/go-ps v1.0.0 9 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 10 | golang.org/x/sync v0.3.0 11 | tailscale.com v1.52.0 12 | ) 13 | 14 | require ( 15 | filippo.io/edwards25519 v1.0.0 // indirect 16 | github.com/akutz/memconn v0.1.0 // indirect 17 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect 18 | github.com/dblohm7/wingoes v0.0.0-20230929194252-e994401fc077 // indirect 19 | github.com/fxamacker/cbor/v2 v2.5.0 // indirect 20 | github.com/google/go-cmp v0.5.9 // indirect 21 | github.com/hdevalence/ed25519consensus v0.1.0 // indirect 22 | github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 // indirect 23 | github.com/jsimonetti/rtnetlink v1.3.5 // indirect 24 | github.com/mdlayher/netlink v1.7.2 // indirect 25 | github.com/mdlayher/socket v0.5.0 // indirect 26 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect 27 | github.com/x448/float16 v0.8.4 // indirect 28 | go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect 29 | go4.org/netipx v0.0.0-20230824141953-6213f710f925 // indirect 30 | golang.org/x/crypto v0.14.0 // indirect 31 | golang.org/x/net v0.17.0 // indirect 32 | golang.org/x/sys v0.13.0 // indirect 33 | golang.org/x/text v0.13.0 // indirect 34 | golang.zx2c4.com/wireguard/windows v0.5.3 // indirect 35 | ) 36 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.0.0 h1:0wAIcmJUqRdI8IJ/3eGi5/HwXZWPujYXXlkrQogz0Ek= 2 | filippo.io/edwards25519 v1.0.0/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= 3 | github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= 4 | github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= 5 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= 6 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= 7 | github.com/cilium/ebpf v0.11.0 h1:V8gS/bTCCjX9uUnkUFUpPsksM8n1lXBAvHcpiFk1X2Y= 8 | github.com/cilium/ebpf v0.11.0/go.mod h1:WE7CZAnqOL2RouJ4f1uyNhqr2P4CCvXFIqdRDUgWsVs= 9 | github.com/dblohm7/wingoes v0.0.0-20230929194252-e994401fc077 h1:WphxHslVftszsr0oZOHPaOjpmN/BsgNYF+gW/hxZXXc= 10 | github.com/dblohm7/wingoes v0.0.0-20230929194252-e994401fc077/go.mod h1:6NCrWM5jRefaG7iN0iMShPalLsljHWBh9v1zxM2f8Xs= 11 | github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA= 12 | github.com/frankban/quicktest v1.14.5/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 13 | github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE= 14 | github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= 15 | github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= 16 | github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 17 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 18 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 19 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 20 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 21 | github.com/hdevalence/ed25519consensus v0.1.0 h1:jtBwzzcHuTmFrQN6xQZn6CQEO/V9f7HsjsjeEZ6auqU= 22 | github.com/hdevalence/ed25519consensus v0.1.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= 23 | github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 h1:elKwZS1OcdQ0WwEDBeqxKwb7WB62QX8bvZ/FJnVXIfk= 24 | github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86/go.mod h1:aFAMtuldEgx/4q7iSGazk22+IcgvtiC+HIimFO9XlS8= 25 | github.com/jsimonetti/rtnetlink v1.3.5 h1:hVlNQNRlLDGZz31gBPicsG7Q53rnlsz1l1Ix/9XlpVA= 26 | github.com/jsimonetti/rtnetlink v1.3.5/go.mod h1:0LFedyiTkebnd43tE4YAkWGIq9jQphow4CcwxaT2Y00= 27 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 28 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 29 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 30 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 31 | github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= 32 | github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= 33 | github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= 34 | github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= 35 | github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= 36 | github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= 37 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 38 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 39 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= 40 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= 41 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 42 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 43 | go4.org/mem v0.0.0-20220726221520-4f986261bf13 h1:CbZeCBZ0aZj8EfVgnqQcYZgf0lpZ3H9rmp5nkDTAst8= 44 | go4.org/mem v0.0.0-20220726221520-4f986261bf13/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= 45 | go4.org/netipx v0.0.0-20230824141953-6213f710f925 h1:eeQDDVKFkx0g4Hyy8pHgmZaK0EqB4SD6rvKbUdN3ziQ= 46 | go4.org/netipx v0.0.0-20230824141953-6213f710f925/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= 47 | golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= 48 | golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= 49 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= 50 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= 51 | golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= 52 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 53 | golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= 54 | golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= 55 | golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= 56 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 57 | golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 58 | golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= 59 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 60 | golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= 61 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 62 | golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= 63 | golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= 64 | tailscale.com v1.52.0 h1:k97Lb1dIrz3ZwJaki7NJDHImRG3nNLEnCO+x+44WQLU= 65 | tailscale.com v1.52.0/go.mod h1:XhC0ZVDuF43FVrmr7oB4Qv/2kdj64YqOH15O2THCung= 66 | -------------------------------------------------------------------------------- /node.rev: -------------------------------------------------------------------------------- 1 | .nvmrc -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | singleQuote: true, 4 | printWidth: 100, 5 | tabWidth: 2, 6 | useTabs: false, 7 | trailingComma: 'es5', 8 | bracketSpacing: true, 9 | }; 10 | -------------------------------------------------------------------------------- /profiles/offline.json: -------------------------------------------------------------------------------- 1 | { 2 | "MockOffline": true 3 | } 4 | -------------------------------------------------------------------------------- /profiles/snapshot.mjs: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process'; 2 | import fs from 'fs'; 3 | 4 | function executeCommand(command) { 5 | const fullCommand = `tailscale ${command} --json`; 6 | return new Promise((resolve, reject) => { 7 | exec(fullCommand, (error, stdout, stderr) => { 8 | if (error) { 9 | // If command not found in PATH, try with zsh 10 | if (error.code === 127) { 11 | exec(`zsh -i -c "${fullCommand}"`, (zshError, zshStdout, zshStderr) => { 12 | if (zshError) { 13 | reject(zshError); 14 | } else { 15 | resolve(JSON.parse(zshStdout.trim())); 16 | } 17 | }); 18 | } else { 19 | reject(error); 20 | } 21 | } else { 22 | try { 23 | const parsedOutput = JSON.parse(stdout.trim()); 24 | resolve(parsedOutput); 25 | } catch (parseError) { 26 | resolve({}); 27 | } 28 | } 29 | }); 30 | }); 31 | } 32 | 33 | function exportResults(profileName, results) { 34 | const filename = `${profileName}.json`; 35 | fs.writeFileSync(filename, JSON.stringify(results, null, 2)); 36 | console.log(`Results exported to ${filename}`); 37 | } 38 | 39 | async function runCommands(profileName) { 40 | try { 41 | const results = {}; 42 | 43 | results.Status = await executeCommand('status'); 44 | results.ServeConfig = await executeCommand('serve status'); 45 | 46 | // Export results to JSON file 47 | exportResults(profileName, results); 48 | } catch (error) { 49 | console.error('Error:', error.message); 50 | throw error; 51 | } 52 | } 53 | 54 | const profileName = process.argv[2]; 55 | 56 | if (profileName) { 57 | runCommands(profileName); 58 | } else { 59 | console.error('Please provide a profile name as an argument.'); 60 | } 61 | -------------------------------------------------------------------------------- /resources/dark/offline.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/dark/online.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/dark/terminal.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/images/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale-dev/vscode-tailscale/490a435605f20f90905bac9a28fe5b9a82f48b78/resources/images/icon_256x256.png -------------------------------------------------------------------------------- /resources/images/mark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /resources/light/offline.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/light/online.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/light/terminal.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/readme/funnel-palette-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale-dev/vscode-tailscale/490a435605f20f90905bac9a28fe5b9a82f48b78/resources/readme/funnel-palette-demo.gif -------------------------------------------------------------------------------- /resources/readme/funnel-panel-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale-dev/vscode-tailscale/490a435605f20f90905bac9a28fe5b9a82f48b78/resources/readme/funnel-panel-demo.gif -------------------------------------------------------------------------------- /resources/readme/funnel-port-disco-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale-dev/vscode-tailscale/490a435605f20f90905bac9a28fe5b9a82f48b78/resources/readme/funnel-port-disco-demo.gif -------------------------------------------------------------------------------- /resources/readme/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale-dev/vscode-tailscale/490a435605f20f90905bac9a28fe5b9a82f48b78/resources/readme/header.png -------------------------------------------------------------------------------- /resources/readme/machine-explorer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale-dev/vscode-tailscale/490a435605f20f90905bac9a28fe5b9a82f48b78/resources/readme/machine-explorer.png -------------------------------------------------------------------------------- /resources/readme/remote-explorer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale-dev/vscode-tailscale/490a435605f20f90905bac9a28fe5b9a82f48b78/resources/readme/remote-explorer.png -------------------------------------------------------------------------------- /resources/readme/terminal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale-dev/vscode-tailscale/490a435605f20f90905bac9a28fe5b9a82f48b78/resources/readme/terminal.png -------------------------------------------------------------------------------- /resources/walkthrough/about.md: -------------------------------------------------------------------------------- 1 | # About Tailscale 2 | 3 | Tailscale is a free and open source service, based on WireGuard®, that helps users build no-hassle virtual private networks. With a Tailscale network (tailnet), you can securely access services and devices on that tailnet from anywhere in the world. Tailnets can include anything from a Digital Ocean droplet to a Raspberry Pi, home security camera, or even a Steam Deck. You can share nodes with friends or co-workers, or even expose ports to the public internet with Tailscale Funnel. 4 | 5 | Learn more at [tailscale.com](https://tailscale.com) 6 | -------------------------------------------------------------------------------- /resources/walkthrough/enable-funnel.md: -------------------------------------------------------------------------------- 1 | # Enable Funnel 2 | 3 | In order to allow local services to be exposed externally with this extension, Funnel needs to be enabled. This is done in the [admin console](https://login.tailscale.com/admin/acls/file) under Access Controls. 4 | 5 | For more information about Funnel, refer to the [Tailscale documentation](https://tailscale.com/kb/1223/tailscale-funnel/). 6 | -------------------------------------------------------------------------------- /resources/walkthrough/enable-https.md: -------------------------------------------------------------------------------- 1 | # Enable HTTPS 2 | 3 | You must [enable HTTPS certificates](https://tailscale.com/kb/1153/enabling-https/) for your tailnet in order to use Tailscale Funnel. To turn on HTTPS: 4 | 5 | 1. Navigate to the [**DNS**](https://login.tailscale.com/admin/dns) page of the admin console. 6 | 2. Enable [MagicDNS](https://tailscale.com/kb/1081/magicdns/#enabling-magicdns) if not already enabled for your tailnet. 7 | 3. Under **HTTPS Certificates**, click **Enable HTTPS**. 8 | 9 | Additional information is ability for enabling HTTPS [here](https://tailscale.com/kb/1153/enabling-https/). 10 | -------------------------------------------------------------------------------- /resources/walkthrough/install/linux.md: -------------------------------------------------------------------------------- 1 | # Use Tailscale on Linux 2 | 3 | ## Install with one command 4 | 5 | ``` 6 | curl -fsSL https://tailscale.com/install.sh | sh 7 | ``` 8 | 9 | If you prefer to install manually, visit the [Linux download page](https://tailscale.com/download/linux) and select your distro. 10 | 11 | After installation, run `sudo tailscale up` to sign in to Tailscale. 12 | 13 | You should be installed and connected! Set up more devices to connect them to your network, or log in to the [admin console](https://login.tailscale.com/admin) to manage existing devices. 14 | -------------------------------------------------------------------------------- /resources/walkthrough/install/macos.md: -------------------------------------------------------------------------------- 1 | # Use Tailscale on macOS 2 | 3 | 1. Install Tailscale from the [App Store](https://apps.apple.com/us/app/tailscale/id1475387142) 4 | 2. Accept any prompts to install VPN configurations that may appear 5 | 3. Click _Log in_ from the Tailscale menu bar item and authenticate in your browser 6 | 4. Sign up with your email address 7 | 8 | You should be logged in and connected! Set up more devices to connect them to your network, or log in to the [admin console](https://login.tailscale.com/admin) to manage existing devices. 9 | -------------------------------------------------------------------------------- /resources/walkthrough/install/windows.md: -------------------------------------------------------------------------------- 1 | # Use Tailscale on Windows 2 | 3 | 1. Download and run the Windows installer 4 | 2. Click on Log in from the Tailscale icon now in your system tray and authenticate in your browser 5 | 3. Sign up with your email address 6 | 7 | You should be logged in and connected! Set up more devices to connect them to your network, or log in to the [admin console](https://login.tailscale.com/admin) to manage existing devices. 8 | -------------------------------------------------------------------------------- /resources/walkthrough/share-port.md: -------------------------------------------------------------------------------- 1 | # Share a port 2 | 3 | You're all set! Let's share a port with Tailscale Funnel to get started. 4 | 5 | You can do this from the [Funnel UI](command:serve-view.focus) or using [Tailscale: Share port publicly using Funnel](command:tailscale.sharePortOverTunnel) from the command palette. 6 | -------------------------------------------------------------------------------- /src/config-manager.test.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import { test, expect, beforeEach, vi } from 'vitest'; 5 | import { ConfigManager } from './config-manager'; 6 | 7 | const fsPath = '/tmp/vscode-tailscale'; 8 | const globalStorageUri = { fsPath } as vscode.Uri; 9 | const configPath = path.join(fsPath, 'config.json'); 10 | 11 | beforeEach(() => { 12 | if (fs.existsSync(configPath)) { 13 | fs.unlinkSync(configPath); 14 | } 15 | }); 16 | 17 | test('withContext will create directory if it does not exist', () => { 18 | fs.rmSync(fsPath, { recursive: true, force: true }); 19 | expect(fs.existsSync(fsPath)).toBe(false); 20 | 21 | ConfigManager.withGlobalStorageUri(globalStorageUri); 22 | expect(fs.existsSync(fsPath)).toBe(true); 23 | }); 24 | 25 | test('withContext returns an initialized ConfigManager', () => { 26 | const cm = ConfigManager.withGlobalStorageUri(globalStorageUri); 27 | expect(cm.configPath).toBe(configPath); 28 | }); 29 | 30 | test('set persists config to disk', () => { 31 | const cm = new ConfigManager(configPath); 32 | const hosts = { 33 | 'host-1': { 34 | user: 'foo', 35 | rootDir: '/', 36 | }, 37 | }; 38 | 39 | cm.set('hosts', hosts); 40 | expect(cm.config.hosts).toEqual(hosts); 41 | 42 | const f = fs.readFileSync(configPath, 'utf8'); 43 | expect(JSON.parse(f)).toEqual({ hosts }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/config-manager.ts: -------------------------------------------------------------------------------- 1 | import { Uri } from 'vscode'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | 5 | interface Host { 6 | user: string; 7 | rootDir: string; 8 | persistToSSHConfig?: boolean; 9 | differentUserFromSSHConfig?: boolean; 10 | } 11 | 12 | interface Config { 13 | defaultHost?: Host; 14 | hosts?: Record; 15 | } 16 | 17 | export class ConfigManager { 18 | private _config: Config; 19 | 20 | constructor(public readonly configPath: string) { 21 | if (fs.existsSync(this.configPath)) { 22 | const rawData = fs.readFileSync(this.configPath, 'utf8'); 23 | this._config = JSON.parse(rawData); 24 | } else { 25 | this._config = {}; 26 | } 27 | } 28 | 29 | static withGlobalStorageUri(globalStorageUri: Uri) { 30 | const globalStoragePath = globalStorageUri.fsPath; 31 | 32 | if (!fs.existsSync(globalStoragePath)) { 33 | fs.mkdirSync(globalStoragePath); 34 | } 35 | 36 | return new ConfigManager(path.join(globalStoragePath, 'config.json')); 37 | } 38 | 39 | set(key: K, value: Config[K]) { 40 | this._config[key] = value; 41 | this.saveConfig(); 42 | } 43 | 44 | public get config(): Config { 45 | return this._config; 46 | } 47 | 48 | setForHost( 49 | hostname: string, 50 | key: TKey, 51 | value: TValue 52 | ) { 53 | this._config.hosts = this._config.hosts ?? {}; 54 | this._config.hosts[hostname] = this._config.hosts[hostname] ?? {}; 55 | this._config.hosts[hostname][key] = value; 56 | 57 | this.saveConfig(); 58 | } 59 | 60 | private saveConfig() { 61 | fs.writeFileSync(this.configPath, JSON.stringify(this._config, null, 2), 'utf8'); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const EXTENSION_NS = 'tailscale'; 2 | export const EXTENSION_ID = 'tailscale.vscode-tailscale'; 3 | export const MIN_VERSION = '1.38.1'; 4 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as vscode from 'vscode'; 3 | 4 | import { ServePanelProvider } from './serve-panel-provider'; 5 | import { ADMIN_CONSOLE, KB_DOCS_URL as KB_DOCS_URL } from './utils/url'; 6 | import { Tailscale } from './tailscale'; 7 | import { Logger } from './logger'; 8 | import { errorForType } from './tailscale/error'; 9 | import { 10 | FileExplorer, 11 | NodeExplorerProvider, 12 | PeerRoot, 13 | PeerErrorItem, 14 | } from './node-explorer-provider'; 15 | 16 | import { FileSystemProviderSFTP } from './filesystem-provider-sftp'; 17 | import { ConfigManager } from './config-manager'; 18 | import { parseTsUri } from './utils/uri'; 19 | import { WithFSTiming } from './filesystem-provider-timing'; 20 | import { FileSystemProvider } from './filesystem-provider'; 21 | 22 | let tailscaleInstance: Tailscale; 23 | 24 | export async function activate(context: vscode.ExtensionContext) { 25 | vscode.commands.executeCommand('setContext', 'tailscale.env', process.env.NODE_ENV); 26 | 27 | tailscaleInstance = await Tailscale.withInit(vscode); 28 | 29 | const configManager = ConfigManager.withGlobalStorageUri(context.globalStorageUri); 30 | 31 | // walkthrough completion 32 | tailscaleInstance.serveStatus().then((status) => { 33 | // assume if we have any BackendState we are installed 34 | const isInstalled = status.BackendState !== ''; 35 | vscode.commands.executeCommand('setContext', 'tailscale.walkthroughs.installed', isInstalled); 36 | 37 | // Funnel check 38 | const isFunnelOn = !status?.Errors?.some((e) => e.Type === 'FUNNEL_OFF'); 39 | Logger.info(`Funnel is ${isFunnelOn ? 'on' : 'off'}`, 'serve-status'); 40 | vscode.commands.executeCommand('setContext', 'tailscale.walkthroughs.funnelOn', isFunnelOn); 41 | 42 | // HTTPS check 43 | const isHTTPSOn = !status?.Errors?.some((e) => e.Type === 'HTTPS_OFF'); 44 | Logger.info(`HTTPS is ${isFunnelOn && isHTTPSOn ? 'on' : 'off'}`, 'serve-status'); 45 | vscode.commands.executeCommand( 46 | 'setContext', 47 | 'tailscale.walkthroughs.httpsOn', 48 | isFunnelOn && isHTTPSOn 49 | ); 50 | 51 | if (status?.ServeConfig && Object.keys(status.ServeConfig).length === 0) { 52 | vscode.commands.executeCommand('setContext', 'tailscale.walkthroughs.sharedPort', true); 53 | } 54 | }); 55 | 56 | const servePanelProvider = new ServePanelProvider( 57 | process.env.NODE_ENV === 'development' 58 | ? vscode.Uri.parse('http://127.0.0.1:8000') 59 | : vscode.Uri.joinPath(context.extensionUri, 'dist'), 60 | tailscaleInstance 61 | ); 62 | 63 | let fileSystemProvider: FileSystemProvider = new FileSystemProviderSFTP(configManager); 64 | fileSystemProvider = new WithFSTiming(fileSystemProvider); 65 | 66 | context.subscriptions.push( 67 | vscode.workspace.registerFileSystemProvider('ts', fileSystemProvider, { 68 | isCaseSensitive: true, 69 | }) 70 | ); 71 | 72 | // eslint-disable-next-line prefer-const 73 | let nodeExplorerView: vscode.TreeView; 74 | 75 | function updateNodeExplorerDisplayName(name: string) { 76 | nodeExplorerView.title = name; 77 | } 78 | 79 | const createNodeExplorerView = (): vscode.TreeView => { 80 | return vscode.window.createTreeView('node-explorer-view', { 81 | treeDataProvider: nodeExplorerProvider, 82 | showCollapseAll: true, 83 | dragAndDropController: nodeExplorerProvider, 84 | }); 85 | }; 86 | 87 | const nodeExplorerProvider = new NodeExplorerProvider( 88 | tailscaleInstance, 89 | configManager, 90 | fileSystemProvider, 91 | updateNodeExplorerDisplayName 92 | ); 93 | 94 | vscode.window.registerTreeDataProvider('node-explorer-view', nodeExplorerProvider); 95 | nodeExplorerView = createNodeExplorerView(); 96 | context.subscriptions.push(nodeExplorerView); 97 | 98 | context.subscriptions.push( 99 | vscode.commands.registerCommand('tailscale.refreshServe', () => { 100 | Logger.info('called tailscale.refreshServe', 'command'); 101 | servePanelProvider.refreshState(); 102 | }) 103 | ); 104 | 105 | context.subscriptions.push( 106 | vscode.commands.registerCommand('tailscale.resetServe', async () => { 107 | Logger.info('called tailscale.resetServe', 'command'); 108 | await tailscaleInstance.serveDelete(); 109 | servePanelProvider.refreshState(); 110 | 111 | vscode.window.showInformationMessage('Serve configuration reset'); 112 | }) 113 | ); 114 | 115 | context.subscriptions.push( 116 | vscode.commands.registerCommand('tailscale.openFunnelPanel', () => { 117 | vscode.commands.executeCommand('serve-view.focus'); 118 | }) 119 | ); 120 | 121 | context.subscriptions.push( 122 | vscode.commands.registerCommand('tailscale.openAdminConsole', () => { 123 | vscode.env.openExternal(vscode.Uri.parse(ADMIN_CONSOLE)); 124 | }) 125 | ); 126 | 127 | context.subscriptions.push( 128 | vscode.commands.registerCommand('tailscale.openVSCodeDocs', () => { 129 | vscode.env.openExternal(vscode.Uri.parse(KB_DOCS_URL)); 130 | }) 131 | ); 132 | 133 | context.subscriptions.push( 134 | vscode.commands.registerCommand('tailscale.node.setUsername', async (node: PeerRoot) => { 135 | const username = await vscode.window.showInputBox({ 136 | prompt: `Enter the username to use for ${node.ServerName}`, 137 | value: configManager.config?.hosts?.[node.Address]?.user, 138 | }); 139 | 140 | if (!username) { 141 | return; 142 | } 143 | 144 | configManager.setForHost(node.Address, 'user', username); 145 | }) 146 | ); 147 | 148 | context.subscriptions.push( 149 | vscode.commands.registerCommand( 150 | 'tailscale.node.setRootDir', 151 | async (node: PeerRoot | FileExplorer | PeerErrorItem) => { 152 | let address: string; 153 | 154 | if (node instanceof FileExplorer) { 155 | address = parseTsUri(node.uri).address; 156 | } else if (node instanceof PeerRoot) { 157 | address = node.Address; 158 | } else { 159 | throw new Error(`invalid node type: ${typeof node}`); 160 | } 161 | 162 | const dir = await vscode.window.showInputBox({ 163 | prompt: `Enter the root directory to use for ${address}`, 164 | value: configManager.config?.hosts?.[address]?.rootDir || '~', 165 | }); 166 | 167 | if (!dir) { 168 | return; 169 | } 170 | 171 | if (!path.isAbsolute(dir) && dir !== '~' && !dir.startsWith('~/')) { 172 | vscode.window.showErrorMessage(`${dir} is an invalid absolute path`); 173 | return; 174 | } 175 | 176 | configManager.setForHost(address, 'rootDir', dir); 177 | nodeExplorerProvider.refresh(); 178 | } 179 | ) 180 | ); 181 | 182 | vscode.window.registerWebviewViewProvider('serve-view', servePanelProvider); 183 | 184 | context.subscriptions.push( 185 | vscode.commands.registerCommand('tailscale.sharePortOverTunnel', async () => { 186 | Logger.info('called tailscale.sharePortOverTunnel', 'command'); 187 | const port = await vscode.window.showInputBox({ 188 | prompt: 'Port to share', 189 | validateInput: (value) => { 190 | // TODO(all): handle serve already being configured 191 | if (!value) { 192 | return 'Please enter a port'; 193 | } 194 | 195 | if (!Number.isInteger(Number(value))) { 196 | return 'Please enter an integer'; 197 | } 198 | 199 | return null; 200 | }, 201 | }); 202 | 203 | if (!port) { 204 | return; 205 | } 206 | 207 | const status = await tailscaleInstance.serveStatus(); 208 | if (status?.Errors?.length) { 209 | status.Errors.map((err) => { 210 | const e = errorForType(err.Type); 211 | 212 | vscode.window 213 | .showErrorMessage( 214 | `${e.title}. ${e.message}`, 215 | ...(e.links ? e.links.map((l) => l.title) : []) 216 | ) 217 | .then((selection) => { 218 | if (selection) { 219 | if (!e.links) return; 220 | 221 | const link = e.links.find((l) => l.title === selection); 222 | if (link) { 223 | vscode.env.openExternal(vscode.Uri.parse(link.url)); 224 | } 225 | } 226 | }); 227 | }); 228 | } else { 229 | tailscaleInstance.runFunnel(parseInt(port)); 230 | } 231 | }) 232 | ); 233 | 234 | context.subscriptions.push( 235 | vscode.commands.registerCommand('tailscale.reloadServePanel', async () => { 236 | await vscode.commands.executeCommand('workbench.action.closePanel'); 237 | await vscode.commands.executeCommand('serve-view.focus'); 238 | setTimeout(() => { 239 | vscode.commands.executeCommand('workbench.action.toggleDevTools'); 240 | }, 500); 241 | }) 242 | ); 243 | } 244 | 245 | export function deactivate() { 246 | if (tailscaleInstance) { 247 | tailscaleInstance.dispose(); 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /src/filesystem-provider-sftp.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Logger } from './logger'; 3 | import { ConfigManager } from './config-manager'; 4 | import { parseTsUri } from './utils/uri'; 5 | import { SshConnectionManager } from './ssh-connection-manager'; 6 | import { fileSorter } from './filesystem-provider'; 7 | import { getErrorMessage } from './utils/error'; 8 | 9 | export class FileSystemProviderSFTP implements vscode.FileSystemProvider { 10 | public manager: SshConnectionManager; 11 | 12 | constructor(configManager: ConfigManager) { 13 | this.manager = new SshConnectionManager(configManager); 14 | } 15 | 16 | // Implementation of the `onDidChangeFile` event 17 | onDidChangeFile: vscode.Event = new vscode.EventEmitter< 18 | vscode.FileChangeEvent[] 19 | >().event; 20 | 21 | watch(): vscode.Disposable { 22 | throw new Error('Watch not supported'); 23 | } 24 | 25 | readDirectory = withFileSystemErrorHandling( 26 | 'readDirectory', 27 | async (uri: vscode.Uri): Promise<[string, vscode.FileType][]> => { 28 | const { resourcePath, sftp } = await this.getParsedUriAndSftp(uri); 29 | const files = await sftp.readDirectory(resourcePath); 30 | return files.sort(fileSorter); 31 | } 32 | ); 33 | 34 | stat = withFileSystemErrorHandling('stat', async (uri: vscode.Uri): Promise => { 35 | const { resourcePath, sftp } = await this.getParsedUriAndSftp(uri); 36 | return await sftp.stat(resourcePath); 37 | }); 38 | 39 | createDirectory = withFileSystemErrorHandling( 40 | 'createDirectory', 41 | async (uri: vscode.Uri): Promise => { 42 | const { resourcePath, sftp } = await this.getParsedUriAndSftp(uri); 43 | return await sftp.createDirectory(resourcePath); 44 | } 45 | ); 46 | 47 | readFile = withFileSystemErrorHandling( 48 | 'readFile', 49 | async (uri: vscode.Uri): Promise => { 50 | const { resourcePath, sftp } = await this.getParsedUriAndSftp(uri); 51 | return await sftp.readFile(resourcePath); 52 | } 53 | ); 54 | 55 | writeFile = withFileSystemErrorHandling( 56 | 'writeFile', 57 | async (uri: vscode.Uri, content: Uint8Array) => { 58 | const { resourcePath, sftp } = await this.getParsedUriAndSftp(uri); 59 | return await sftp.writeFile(resourcePath, content); 60 | } 61 | ); 62 | 63 | delete = withFileSystemErrorHandling('delete', async (uri: vscode.Uri): Promise => { 64 | const { resourcePath, sftp } = await this.getParsedUriAndSftp(uri); 65 | 66 | const deleteRecursively = async (path: string) => { 67 | const st = await sftp.stat(path); 68 | 69 | // short circuit for files and symlinks 70 | // (don't recursively delete through symlinks that point to directories) 71 | if (st.type & vscode.FileType.File || st.type & vscode.FileType.SymbolicLink) { 72 | await sftp.delete(path); 73 | return; 74 | } 75 | 76 | const files = await sftp.readDirectory(path); 77 | 78 | for (const [file, fileType] of files) { 79 | const filePath = `${path}/${file}`; 80 | 81 | if (fileType & vscode.FileType.Directory) { 82 | await deleteRecursively(filePath); 83 | } else { 84 | await sftp.delete(filePath); 85 | } 86 | } 87 | 88 | await sftp.rmdir(path); 89 | }; 90 | 91 | return await deleteRecursively(resourcePath); 92 | }); 93 | 94 | rename = withFileSystemErrorHandling('rename', async (source: vscode.Uri, target: vscode.Uri) => { 95 | const { resourcePath: sourcePath, sftp } = await this.getParsedUriAndSftp(source); 96 | const { resourcePath: targetPath } = parseTsUri(target); 97 | 98 | return await sftp.rename(sourcePath, targetPath); 99 | }); 100 | 101 | upload = withFileSystemErrorHandling('upload', async (source: vscode.Uri, target: vscode.Uri) => { 102 | const sourcePath = source.path; 103 | const { resourcePath: targetPath, sftp } = await this.getParsedUriAndSftp(target); 104 | 105 | Logger.info(`Uploading ${sourcePath} to ${targetPath}`, 'tsFs-sftp'); 106 | 107 | return await sftp.uploadFile(sourcePath, targetPath); 108 | }); 109 | 110 | async getHomeDirectory(address: string): Promise { 111 | const sftp = await this.manager.getSftp(address); 112 | if (!sftp) throw new Error('Failed to establish SFTP connection'); 113 | 114 | return await sftp.getHomeDirectory(); 115 | } 116 | 117 | async getParsedUriAndSftp(uri: vscode.Uri) { 118 | const { address, resourcePath } = parseTsUri(uri); 119 | const sftp = await this.manager.getSftp(address); 120 | 121 | if (!sftp) { 122 | throw new Error('Unable to establish SFTP connection'); 123 | } 124 | 125 | return { address, resourcePath, sftp }; 126 | } 127 | } 128 | 129 | type FileSystemMethod = ( 130 | uri: vscode.Uri, 131 | ...args: TArgs 132 | ) => Promise; 133 | 134 | function withFileSystemErrorHandling( 135 | actionName: string, 136 | fn: FileSystemMethod 137 | ): FileSystemMethod { 138 | return async (uri: vscode.Uri, ...args: TArgs): Promise => { 139 | Logger.info(`${actionName}: ${uri}`, 'tsFs-sftp'); 140 | 141 | try { 142 | return await fn(uri, ...args); 143 | } catch (error) { 144 | const message = getErrorMessage(error); 145 | 146 | if (error instanceof vscode.FileSystemError) { 147 | throw error; 148 | } 149 | 150 | if (message.includes('no such file or directory')) { 151 | throw vscode.FileSystemError.FileNotFound(); 152 | } 153 | 154 | if (message.includes('permission denied')) { 155 | throw vscode.FileSystemError.NoPermissions(); 156 | } 157 | 158 | if (message.includes('file already exists')) { 159 | const message = `Unable to move/copy`; 160 | throw vscode.FileSystemError.FileExists(message); 161 | } 162 | 163 | if (message.includes('EISDIR')) { 164 | throw vscode.FileSystemError.FileIsADirectory(); 165 | } 166 | 167 | if (message.includes('ENOTDIR')) { 168 | throw vscode.FileSystemError.FileNotADirectory(); 169 | } 170 | 171 | if ( 172 | message.includes('no connection') || 173 | message.includes('connection lost') || 174 | message.includes('Unable to establish SFTP connection') 175 | ) { 176 | throw vscode.FileSystemError.Unavailable(); 177 | } 178 | 179 | Logger.error(`${actionName}: ${error}`, 'tsFs-sftp'); 180 | 181 | throw error; 182 | } 183 | }; 184 | } 185 | -------------------------------------------------------------------------------- /src/filesystem-provider-ssh.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { exec } from 'child_process'; 3 | import { Logger } from './logger'; 4 | import { SSH } from './ssh'; 5 | import { ConfigManager } from './config-manager'; 6 | import { parseTsUri } from './utils/uri'; 7 | import { fileSorter } from './filesystem-provider'; 8 | 9 | export class FileSystemProviderSSH implements vscode.FileSystemProvider { 10 | private ssh: SSH; 11 | 12 | constructor(configManager?: ConfigManager) { 13 | this.ssh = new SSH(configManager); 14 | } 15 | 16 | // Implementation of the `onDidChangeFile` event 17 | onDidChangeFile: vscode.Event = new vscode.EventEmitter< 18 | vscode.FileChangeEvent[] 19 | >().event; 20 | 21 | watch(): vscode.Disposable { 22 | throw new Error('Watch not supported'); 23 | } 24 | 25 | async stat(uri: vscode.Uri): Promise { 26 | Logger.info(`stat: ${uri.toString()}`, 'tsFs-ssh'); 27 | const { address, resourcePath } = parseTsUri(uri); 28 | 29 | if (!address) { 30 | throw new Error('address is undefined'); 31 | } 32 | 33 | const s = await this.ssh.runCommandAndPromptForUsername(address, 'stat', [ 34 | '-L', 35 | '-c', 36 | `'{\\"type\\": \\"%F\\", \\"size\\": %s, \\"ctime\\": %Z, \\"mtime\\": %Y}'`, 37 | resourcePath, 38 | ]); 39 | 40 | const result = JSON.parse(s.trim()); 41 | const type = result.type === 'directory' ? vscode.FileType.Directory : vscode.FileType.File; 42 | const size = result.size || 0; 43 | const ctime = result.ctime * 1000; 44 | const mtime = result.mtime * 1000; 45 | 46 | return { type, size, ctime, mtime }; 47 | } 48 | 49 | async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> { 50 | Logger.info(`readDirectory: ${uri.toString()}`, 'tsFs-ssh'); 51 | 52 | const { address, resourcePath } = parseTsUri(uri); 53 | Logger.info(`hostname: ${address}`, 'tsFs-ssh'); 54 | Logger.info(`remotePath: ${resourcePath}`, 'tsFs-ssh'); 55 | 56 | if (!address) { 57 | throw new Error('hostname is undefined'); 58 | } 59 | 60 | const s = await this.ssh.runCommandAndPromptForUsername(address, 'ls', ['-Ap', resourcePath]); 61 | 62 | const lines = s.trim(); 63 | const files: [string, vscode.FileType][] = []; 64 | for (const line of lines.split('\n')) { 65 | if (line === '') { 66 | continue; 67 | } 68 | const isDirectory = line.endsWith('/'); 69 | const type = isDirectory ? vscode.FileType.Directory : vscode.FileType.File; 70 | const name = isDirectory ? line.slice(0, -1) : line; // Remove trailing slash if it's a directory 71 | files.push([name, type]); 72 | } 73 | 74 | return files.sort(fileSorter); 75 | } 76 | 77 | async getHomeDirectory(hostname: string): Promise { 78 | return (await this.ssh.runCommandAndPromptForUsername(hostname, 'echo', ['~'])).trim(); 79 | } 80 | 81 | async readFile(uri: vscode.Uri): Promise { 82 | Logger.info(`readFile: ${uri.toString()}`, 'tsFs-ssh'); 83 | const { address, resourcePath } = parseTsUri(uri); 84 | 85 | if (!address) { 86 | throw new Error('hostname is undefined'); 87 | } 88 | 89 | const s = await this.ssh.runCommandAndPromptForUsername(address, 'cat', [resourcePath]); 90 | const buffer = Buffer.from(s, 'binary'); 91 | return new Uint8Array(buffer); 92 | } 93 | 94 | async writeFile( 95 | uri: vscode.Uri, 96 | content: Uint8Array, 97 | options: { create: boolean; overwrite: boolean } 98 | ): Promise { 99 | Logger.info(`writeFile: ${uri.toString()}`, 'tsFs-ssh'); 100 | 101 | const { address, resourcePath } = parseTsUri(uri); 102 | 103 | if (!options.create && !options.overwrite) { 104 | throw vscode.FileSystemError.FileExists(uri); 105 | } 106 | 107 | if (!address) { 108 | throw new Error('hostname is undefined'); 109 | } 110 | 111 | await this.ssh.runCommandAndPromptForUsername(address, 'tee', [resourcePath], { 112 | stdin: content.toString(), 113 | }); 114 | } 115 | 116 | async delete(uri: vscode.Uri, options: { recursive: boolean }): Promise { 117 | Logger.info(`delete: ${uri.toString()}`, 'tsFs-ssh'); 118 | 119 | const { address, resourcePath } = parseTsUri(uri); 120 | 121 | if (!address) { 122 | throw new Error('hostname is undefined'); 123 | } 124 | 125 | await this.ssh.runCommandAndPromptForUsername(address, 'rm', [ 126 | `${options.recursive ? '-r' : ''}`, 127 | resourcePath, 128 | ]); 129 | } 130 | 131 | async createDirectory(uri: vscode.Uri): Promise { 132 | Logger.info(`createDirectory: ${uri.toString()}`, 'tsFs-ssh'); 133 | 134 | const { address, resourcePath } = parseTsUri(uri); 135 | 136 | if (!address) { 137 | throw new Error('hostname is undefined'); 138 | } 139 | 140 | await this.ssh.runCommandAndPromptForUsername(address, 'mkdir', ['-p', resourcePath]); 141 | } 142 | 143 | async rename( 144 | oldUri: vscode.Uri, 145 | newUri: vscode.Uri, 146 | options: { overwrite: boolean } 147 | ): Promise { 148 | Logger.info('rename', 'tsFs-ssh'); 149 | 150 | const { address: oldAddr, resourcePath: oldPath } = parseTsUri(oldUri); 151 | const { address: newAddr, resourcePath: newPath } = parseTsUri(newUri); 152 | 153 | if (!oldAddr) { 154 | throw new Error('hostname is undefined'); 155 | } 156 | 157 | if (oldAddr !== newAddr) { 158 | throw new Error('Cannot rename files across different hosts.'); 159 | } 160 | 161 | await this.ssh.runCommandAndPromptForUsername(oldAddr, 'mv', [ 162 | `${options.overwrite ? '-f' : ''}`, 163 | oldPath, 164 | newPath, 165 | ]); 166 | } 167 | 168 | // scp pi@haas:/home/pi/foo.txt ubuntu@backup:/home/ubuntu/ 169 | // scp /Users/Tyler/foo.txt ubuntu@backup:/home/ubuntu/ 170 | // scp ubuntu@backup:/home/ubuntu/ /Users/Tyler/foo.txt 171 | 172 | scp(src: vscode.Uri, dest: vscode.Uri): Promise { 173 | Logger.info('scp', 'tsFs-ssh'); 174 | 175 | const { resourcePath: srcPath } = parseTsUri(src); 176 | const { address: destAddr, resourcePath: destPath } = parseTsUri(dest); 177 | 178 | const command = `scp ${srcPath} ${destAddr}:${destPath}`; 179 | 180 | return new Promise((resolve, reject) => { 181 | exec(command, (error) => { 182 | if (error) { 183 | reject(error); 184 | } else { 185 | resolve(); 186 | } 187 | }); 188 | }); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/filesystem-provider-timing.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Logger } from './logger'; 3 | import { FileSystemProvider } from './filesystem-provider'; 4 | 5 | // WithFSTiming is a FileSystemProvider implementation 6 | // that just wraps each call and logs the timing it took 7 | // for performance comparisons. 8 | export class WithFSTiming implements FileSystemProvider { 9 | constructor(private readonly fsp: FileSystemProvider) {} 10 | 11 | // Implementation of the `onDidChangeFile` event 12 | onDidChangeFile: vscode.Event = new vscode.EventEmitter< 13 | vscode.FileChangeEvent[] 14 | >().event; 15 | 16 | watch( 17 | uri: vscode.Uri, 18 | options: { readonly recursive: boolean; readonly excludes: readonly string[] } 19 | ): vscode.Disposable { 20 | return this.fsp.watch(uri, options); 21 | } 22 | 23 | async stat(uri: vscode.Uri): Promise { 24 | const startTime = new Date().getTime(); 25 | const res = await this.fsp.stat(uri); 26 | const endTime = new Date().getTime(); 27 | Logger.info(`${endTime - startTime}ms for stat`, `tsFs-timing`); 28 | return res; 29 | } 30 | 31 | async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> { 32 | const startTime = new Date().getTime(); 33 | const res = await this.fsp.readDirectory(uri); 34 | const endTime = new Date().getTime(); 35 | Logger.info(`${endTime - startTime}ms for readDirectory`, `tsFs-timing`); 36 | return res; 37 | } 38 | 39 | async createDirectory(uri: vscode.Uri): Promise { 40 | const startTime = new Date().getTime(); 41 | const res = await this.fsp.createDirectory(uri); 42 | const endTime = new Date().getTime(); 43 | Logger.info(`${endTime - startTime}ms for createDirectory`, `tsFs-timing`); 44 | return res; 45 | } 46 | 47 | async readFile(uri: vscode.Uri): Promise { 48 | const startTime = new Date().getTime(); 49 | const res = await this.fsp.readFile(uri); 50 | const endTime = new Date().getTime(); 51 | Logger.info(`${endTime - startTime}ms for readFile`, `tsFs-timing`); 52 | return res; 53 | } 54 | 55 | async writeFile( 56 | uri: vscode.Uri, 57 | content: Uint8Array, 58 | options: { readonly create: boolean; readonly overwrite: boolean } 59 | ): Promise { 60 | const startTime = new Date().getTime(); 61 | const res = await this.fsp.writeFile(uri, content, options); 62 | const endTime = new Date().getTime(); 63 | Logger.info(`${endTime - startTime}ms for writeFile`, `tsFs-timing`); 64 | return res; 65 | } 66 | 67 | async delete(uri: vscode.Uri, options: { readonly recursive: boolean }): Promise { 68 | const startTime = new Date().getTime(); 69 | const res = await this.fsp.delete(uri, options); 70 | const endTime = new Date().getTime(); 71 | Logger.info(`${endTime - startTime}ms for delete`, `tsFs-timing`); 72 | return res; 73 | } 74 | 75 | async rename( 76 | oldUri: vscode.Uri, 77 | newUri: vscode.Uri, 78 | options: { readonly overwrite: boolean } = { overwrite: false } 79 | ): Promise { 80 | const startTime = new Date().getTime(); 81 | const res = await this.fsp.rename(oldUri, newUri, options); 82 | const endTime = new Date().getTime(); 83 | Logger.info(`${endTime - startTime}ms for rename`, `tsFs-timing`); 84 | return res; 85 | } 86 | 87 | async upload(source: vscode.Uri, target: vscode.Uri): Promise { 88 | const startTime = new Date().getTime(); 89 | const res = await this.fsp.upload(source, target); 90 | const endTime = new Date().getTime(); 91 | Logger.info(`${endTime - startTime}ms for upload`, `tsFs-timing`); 92 | return res; 93 | } 94 | 95 | async getHomeDirectory(hostname: string): Promise { 96 | const startTime = new Date().getTime(); 97 | const res = await this.fsp.getHomeDirectory(hostname); 98 | const endTime = new Date().getTime(); 99 | Logger.info(`${endTime - startTime}ms for getHomeDirectory`, `tsFs-timing`); 100 | return res; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/filesystem-provider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | // FileSystemProvider adds to vscode.FileSystemProvider as different 4 | // implementations grab the home directory differently 5 | export interface FileSystemProvider extends vscode.FileSystemProvider { 6 | getHomeDirectory(hostname: string): Promise; 7 | upload(source: vscode.Uri, target: vscode.Uri): Promise; 8 | } 9 | 10 | // fileSorter mimicks the Node Explorer file structure in that directories 11 | // are displayed first in alphabetical followed by files in the same fashion. 12 | export function fileSorter(a: [string, vscode.FileType], b: [string, vscode.FileType]): number { 13 | if (a[1] & vscode.FileType.Directory && !(b[1] & vscode.FileType.Directory)) { 14 | return -1; 15 | } 16 | if (!(a[1] & vscode.FileType.Directory) && b[1] & vscode.FileType.Directory) { 17 | return 1; 18 | } 19 | 20 | // If same type, sort by name 21 | return a[0].localeCompare(b[0]); 22 | } 23 | -------------------------------------------------------------------------------- /src/get-nonce.ts: -------------------------------------------------------------------------------- 1 | export function getNonce() { 2 | let text = ''; 3 | const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 4 | for (let i = 0; i < 32; i++) { 5 | text += possible.charAt(Math.floor(Math.random() * possible.length)); 6 | } 7 | return text; 8 | } 9 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | class Log { 4 | private _outputChannel: vscode.LogOutputChannel; 5 | 6 | constructor() { 7 | this._outputChannel = vscode.window.createOutputChannel('Tailscale', { log: true }); 8 | } 9 | 10 | private logString(message: string, component?: string) { 11 | return component ? `[${component}] ${message}` : message; 12 | } 13 | 14 | public trace(message: string, component: string) { 15 | this._outputChannel.trace(this.logString(message, component)); 16 | } 17 | 18 | public debug(message: string, component?: string) { 19 | this._outputChannel.debug(this.logString(message, component)); 20 | } 21 | 22 | public info(message: string, component?: string) { 23 | this._outputChannel.info(this.logString(message, component)); 24 | } 25 | 26 | public warn(message: string, component?: string) { 27 | this._outputChannel.warn(this.logString(message, component)); 28 | } 29 | 30 | public error(message: string, component?: string) { 31 | this._outputChannel.error(this.logString(message, component)); 32 | } 33 | } 34 | 35 | export const Logger = new Log(); 36 | -------------------------------------------------------------------------------- /src/serve-panel-provider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { getNonce } from './get-nonce'; 3 | import type { Tailscale } from './tailscale'; 4 | import type { Message, WebviewData } from './types'; 5 | import { Logger } from './logger'; 6 | 7 | export class ServePanelProvider implements vscode.WebviewViewProvider { 8 | _view?: vscode.WebviewView; 9 | 10 | constructor( 11 | private readonly _extensionUri: vscode.Uri, 12 | private readonly ts: Tailscale 13 | ) {} 14 | 15 | postMessage(message: WebviewData) { 16 | if (!this._view) { 17 | Logger.warn('No view to update'); 18 | return; 19 | } 20 | 21 | this._view.webview.postMessage(message); 22 | } 23 | 24 | public async refreshState() { 25 | this.postMessage({ 26 | type: 'refreshState', 27 | }); 28 | } 29 | 30 | resolveWebviewView(webviewView: vscode.WebviewView) { 31 | this._view = webviewView; 32 | webviewView.webview.html = this._getHtmlForWebview(webviewView.webview); 33 | webviewView.webview.options = { 34 | enableScripts: true, 35 | localResourceRoots: [this._extensionUri], 36 | }; 37 | 38 | webviewView.webview.onDidReceiveMessage(async (m: Message) => { 39 | switch (m.type) { 40 | case 'refreshState': { 41 | Logger.info('Called refreshState', 'serve-panel'); 42 | await this.refreshState(); 43 | break; 44 | } 45 | 46 | case 'deleteServe': { 47 | Logger.info('Called deleteServe', 'serve-panel'); 48 | try { 49 | await this.ts.serveDelete(m.params); 50 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 51 | } catch (e: any) { 52 | vscode.window.showErrorMessage('Unable to delete serve', e.message); 53 | } 54 | 55 | await this.refreshState(); 56 | break; 57 | } 58 | 59 | case 'addServe': { 60 | Logger.info('Called addServe', 'serve-panel'); 61 | await this.ts.serveAdd(m.params); 62 | await this.refreshState(); 63 | break; 64 | } 65 | 66 | case 'setFunnel': { 67 | Logger.info('Called setFunnel', 'serve-panel'); 68 | try { 69 | await this.ts.setFunnel(parseInt(m.params.port), m.params.allow); 70 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 71 | } catch (e: any) { 72 | vscode.window.showErrorMessage('Unable to toggle funnel', e.message); 73 | } 74 | 75 | await this.refreshState(); 76 | break; 77 | } 78 | 79 | case 'resetServe': { 80 | Logger.info('Called resetServe', 'serve-panel'); 81 | try { 82 | await this.ts.serveDelete(); 83 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 84 | } catch (e: any) { 85 | vscode.window.showErrorMessage('Unable to delete serve', e.message); 86 | } 87 | 88 | await this.refreshState(); 89 | break; 90 | } 91 | 92 | case 'writeToClipboard': { 93 | Logger.info('Called writeToClipboard', 'serve-panel'); 94 | vscode.env.clipboard.writeText(m.params.text); 95 | vscode.window.showInformationMessage('Copied to clipboard'); 96 | break; 97 | } 98 | 99 | case 'openLink': { 100 | Logger.info(`Called openLink: ${m.params.url}`, 'serve-panel'); 101 | vscode.env.openExternal(vscode.Uri.parse(m.params.url)); 102 | break; 103 | } 104 | 105 | case 'sudoPrompt': { 106 | Logger.info('running tsrelay in sudo'); 107 | try { 108 | await this.ts.initSudo(); 109 | Logger.info(`re-applying ${m.operation}`); 110 | if (m.operation == 'add') { 111 | if (!m.params) { 112 | Logger.error('params cannot be null for an add operation'); 113 | return; 114 | } 115 | await this.ts.serveAdd(m.params); 116 | } else if (m.operation == 'delete') { 117 | await this.ts.serveDelete(); 118 | } 119 | } catch (e) { 120 | Logger.error(`error running sudo prompt: ${e}`); 121 | } 122 | break; 123 | } 124 | 125 | default: { 126 | console.log('Unknown type for message', m); 127 | } 128 | } 129 | }); 130 | } 131 | 132 | public revive(panel: vscode.WebviewView) { 133 | this._view = panel; 134 | } 135 | 136 | private _getHtmlForWebview(webview: vscode.Webview) { 137 | const scriptUri = webview.asWebviewUri( 138 | vscode.Uri.joinPath(this._extensionUri, 'serve-panel.js') 139 | ); 140 | 141 | const styleUri = webview.asWebviewUri( 142 | vscode.Uri.joinPath(this._extensionUri, 'serve-panel.css') 143 | ); 144 | 145 | const nonce = getNonce(); 146 | 147 | return /*html*/ ` 148 | 149 | 150 | 151 | 152 | 153 | 154 | 161 | 162 |
163 | 164 | 165 | 166 | 167 | `; 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/sftp.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as ssh2 from 'ssh2'; 3 | import * as util from 'util'; 4 | import * as vscode from 'vscode'; 5 | 6 | export class Sftp { 7 | private sftpPromise: Promise; 8 | 9 | constructor(private conn: ssh2.Client) { 10 | this.sftpPromise = util.promisify(this.conn.sftp).call(this.conn); 11 | } 12 | 13 | private async getSftp(): Promise { 14 | return this.sftpPromise; 15 | } 16 | 17 | async readSymbolicLink(linkPath: string): Promise { 18 | const sftp = await this.getSftp(); 19 | let result = await util.promisify(sftp.readlink).call(sftp, linkPath); 20 | 21 | // if link is relative, not absolute 22 | if (!result.startsWith('/')) { 23 | // note: this needs to be / even on Windows, so don't use path.join() 24 | result = `${path.dirname(linkPath)}/${result}`; 25 | } 26 | 27 | return result; 28 | } 29 | 30 | async readDirectory(path: string): Promise<[string, vscode.FileType][]> { 31 | const sftp = await this.getSftp(); 32 | const files = await util.promisify(sftp.readdir).call(sftp, path); 33 | const result: [string, vscode.FileType][] = []; 34 | 35 | for (const file of files) { 36 | result.push([ 37 | file.filename, 38 | await this.convertFileType(file.attrs as ssh2.Stats, `${path}/${file.filename}`), 39 | ]); 40 | } 41 | 42 | return result; 43 | } 44 | 45 | async getHomeDirectory(): Promise { 46 | const sftp = await this.getSftp(); 47 | return await util.promisify(sftp.realpath).call(sftp, '.'); 48 | } 49 | 50 | async stat(path: string): Promise { 51 | const sftp = await this.getSftp(); 52 | // sftp.lstat, when stat-ing symlinks, will stat the links themselves 53 | // instead of following them. it's necessary to do this and then follow 54 | // the symlinks manually in convertFileType since file.attrs from sftp.readdir 55 | // returns a Stats object that claims to be a symbolic link, but neither a 56 | // file nor a directory. so convertFileType needs to follow symlinks manually 57 | // to figure out whether they point to a file or directory and correctly 58 | // populate the vscode.FileType bitfield. this also allows symlinks to directories 59 | // to not accidentally be treated as directories themselves, so deleting a symlink 60 | // doesn't delete the contents of the directory it points to. 61 | const s = await util.promisify(sftp.lstat).call(sftp, path); 62 | 63 | return { 64 | type: await this.convertFileType(s, path), 65 | ctime: s.atime, 66 | mtime: s.mtime, 67 | size: s.size, 68 | }; 69 | } 70 | 71 | async createDirectory(path: string): Promise { 72 | const sftp = await this.getSftp(); 73 | return util.promisify(sftp.mkdir).call(sftp, path); 74 | } 75 | 76 | async readFile(path: string): Promise { 77 | const sftp = await this.getSftp(); 78 | const buffer = await util.promisify(sftp.readFile).call(sftp, path); 79 | return new Uint8Array(buffer); 80 | } 81 | 82 | async writeFile(path: string, data: Uint8Array | string): Promise { 83 | const sftp = await this.getSftp(); 84 | const buffer = 85 | data instanceof Uint8Array 86 | ? Buffer.from(data.buffer, data.byteOffset, data.byteLength) 87 | : Buffer.from(data); 88 | return util.promisify(sftp.writeFile).call(sftp, path, buffer); 89 | } 90 | 91 | async delete(path: string): Promise { 92 | const sftp = await this.getSftp(); 93 | return util.promisify(sftp.unlink).call(sftp, path); 94 | } 95 | 96 | async rename(oldPath: string, newPath: string): Promise { 97 | const sftp = await this.getSftp(); 98 | return util.promisify(sftp.rename).call(sftp, oldPath, newPath); 99 | } 100 | 101 | async rmdir(path: string): Promise { 102 | const sftp = await this.getSftp(); 103 | return util.promisify(sftp.rmdir).call(sftp, path); 104 | } 105 | 106 | async uploadFile(localPath: string, remotePath: string): Promise { 107 | console.log('uploadFile', localPath, remotePath); 108 | const sftp = await this.getSftp(); 109 | return util.promisify(sftp.fastPut).call(sftp, localPath, remotePath); 110 | } 111 | 112 | async convertFileType(stats: ssh2.Stats, filename: string): Promise { 113 | if (stats.isDirectory()) { 114 | return vscode.FileType.Directory; 115 | } else if (stats.isFile()) { 116 | return vscode.FileType.File; 117 | } else if (stats.isSymbolicLink()) { 118 | const sftp = await this.getSftp(); 119 | const target = await this.readSymbolicLink(filename); 120 | const tStat = await util.promisify(sftp.stat).call(sftp, target); 121 | return vscode.FileType.SymbolicLink | (await this.convertFileType(tStat, target)); 122 | } else { 123 | return vscode.FileType.Unknown; 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/ssh-connection-manager.ts: -------------------------------------------------------------------------------- 1 | import * as ssh2 from 'ssh2'; 2 | import * as vscode from 'vscode'; 3 | 4 | import { ConfigManager } from './config-manager'; 5 | import { getUsername } from './utils/host'; 6 | import { Sftp } from './sftp'; 7 | import { EXTENSION_NS } from './constants'; 8 | import { Logger } from './logger'; 9 | 10 | export class SshConnectionManager { 11 | private connections: Map; 12 | private configManager: ConfigManager; 13 | 14 | constructor(configManager: ConfigManager) { 15 | this.connections = new Map(); 16 | this.configManager = configManager; 17 | } 18 | 19 | async getConnection(host: string, username: string): Promise { 20 | const key = this.formatKey(host, username); 21 | 22 | if (this.connections.has(key)) { 23 | return this.connections.get(key) as ssh2.Client; 24 | } 25 | 26 | const conn = new ssh2.Client(); 27 | const config = { host, username }; 28 | 29 | try { 30 | await Promise.race([ 31 | new Promise((resolve, reject): void => { 32 | conn.on('ready', resolve); 33 | conn.on('error', reject); 34 | conn.on('close', () => { 35 | this.connections.delete(key); 36 | }); 37 | conn.on('banner', (message) => { 38 | const isWrongUser = message && message.includes(`failed to look up ${username}`); 39 | if (isWrongUser) { 40 | reject({ level: 'wrong-user' }); 41 | } 42 | }); 43 | 44 | // this might require a brower to open and the user to authenticate 45 | conn.connect(config); 46 | }), 47 | new Promise((_, reject) => 48 | // TODO: how does Tailscale re-authentication effect this? 49 | // TODO: can we cancel the connection attempt? 50 | setTimeout( 51 | () => reject(new Error('Connection timeout')), 52 | vscode.workspace.getConfiguration(EXTENSION_NS).get('ssh.connectionTimeout') 53 | ) 54 | ), 55 | ]); 56 | 57 | this.connections.set(key, conn); 58 | 59 | return conn; 60 | } catch (err) { 61 | let message = 'Unknown error'; 62 | if (err instanceof Error) { 63 | message = err.message; 64 | } 65 | 66 | const logmsg = `Failed to connect to ${host} with username ${username}: ${message}`; 67 | Logger.error(logmsg, `ssh-conn-manager`); 68 | if (!this.isAuthenticationError(err)) { 69 | vscode.window.showErrorMessage(logmsg); 70 | } 71 | throw err; 72 | } 73 | } 74 | 75 | async getSftp(address: string): Promise { 76 | const username = getUsername(this.configManager, address); 77 | try { 78 | const conn = await this.getConnection(address, username); 79 | return new Sftp(conn); 80 | } catch (err) { 81 | if (this.isAuthenticationError(err)) { 82 | this.displayAuthenticationError(err.level, username, address); 83 | if (await this.promptForUsername(address)) { 84 | return await this.getSftp(address); 85 | } 86 | } 87 | throw err; 88 | } 89 | } 90 | 91 | async displayAuthenticationError(level: string, username: string, address: string) { 92 | if (level === 'wrong-user') { 93 | vscode.window.showWarningMessage( 94 | `The username '${username}' is not valid on host ${address}` 95 | ); 96 | } else { 97 | const msg = `We couldn't connect to the node. Ensure Tailscale SSH is permitted in ACLs, and the username is correct.`; 98 | const action = await vscode.window.showWarningMessage(msg, 'Learn more'); 99 | if (action) { 100 | vscode.env.openExternal( 101 | vscode.Uri.parse( 102 | 'https://tailscale.com/kb/1193/tailscale-ssh/#ensure-tailscale-ssh-is-permitted-in-acls' 103 | ) 104 | ); 105 | } 106 | } 107 | } 108 | 109 | private isAuthenticationError(err: unknown): err is { level: string } { 110 | return ( 111 | typeof err === 'object' && 112 | err !== null && 113 | 'level' in err && 114 | (err.level === 'client-authentication' || err.level === 'wrong-user') 115 | ); 116 | } 117 | 118 | async promptForUsername(address: string): Promise { 119 | const username = await vscode.window.showInputBox({ 120 | prompt: `Please enter a valid username for host "${address}"`, 121 | }); 122 | 123 | if (username && this.configManager) { 124 | this.configManager.setForHost(address, 'user', username); 125 | } 126 | 127 | return username; 128 | } 129 | 130 | closeConnection(hostname: string): void { 131 | const key = this.formatKey(hostname); 132 | const connection = this.connections.get(key); 133 | 134 | if (connection) { 135 | connection.end(); 136 | this.connections.delete(key); 137 | } 138 | } 139 | 140 | private formatKey(hostname: string, username?: string): string { 141 | const u = username || getUsername(this.configManager, hostname); 142 | return `${u}@${hostname}`; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/ssh.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process'; 2 | import * as vscode from 'vscode'; 3 | import { Logger } from './logger'; 4 | import { ConfigManager } from './config-manager'; 5 | 6 | export class SSH { 7 | constructor(private readonly configManager?: ConfigManager) {} 8 | 9 | executeCommand( 10 | hostname: string, 11 | command: string, 12 | args: string[], 13 | options?: { stdin?: string; sudoPassword?: string } 14 | ): Promise { 15 | return new Promise((resolve, reject) => { 16 | const sshArgs: string[] = []; 17 | 18 | if (options?.sudoPassword) { 19 | sshArgs.push('sudo', '-S', command, ...args); 20 | } else { 21 | sshArgs.push(command, ...args); 22 | } 23 | 24 | const cmdForPrint = `ssh ${this.sshHostnameWithUser(hostname)} "${sshArgs.join(' ')}"`; 25 | 26 | Logger.info(`Running command: ${sshArgs.join(' ')}`, 'ssh'); 27 | const childProcess = spawn( 28 | 'ssh', 29 | [this.sshHostnameWithUser(hostname), `"${sshArgs.join(' ')}"`], 30 | { shell: true } 31 | ); 32 | 33 | childProcess.on('error', (err) => { 34 | reject(err); 35 | }); 36 | 37 | if (options?.sudoPassword) { 38 | childProcess.stdin.write(options.sudoPassword + '\n'); 39 | } 40 | 41 | if (options?.stdin) { 42 | childProcess.stdin.write(options.stdin); 43 | childProcess.stdin.end(); 44 | } 45 | 46 | let stdoutData = ''; 47 | childProcess.stdout.on('data', (data) => { 48 | stdoutData += data; 49 | }); 50 | 51 | let stderrData = ''; 52 | childProcess.stderr.on('data', (data) => { 53 | stderrData += data; 54 | }); 55 | 56 | childProcess.on('exit', (code) => { 57 | if (code === 0) { 58 | resolve(stdoutData); 59 | } else if (stderrData) { 60 | reject(new Error(stderrData)); 61 | } else { 62 | reject(new Error(`Command (${cmdForPrint}): ${code}`)); 63 | } 64 | }); 65 | }); 66 | } 67 | 68 | async promptForUsername(hostname: string): Promise { 69 | const username = await vscode.window.showInputBox({ 70 | prompt: `Please enter the username for host "${hostname}"`, 71 | }); 72 | 73 | if (username && this.configManager) { 74 | this.configManager.setForHost(hostname, 'user', username); 75 | } 76 | 77 | return username; 78 | } 79 | 80 | async runCommandAndPromptForUsername( 81 | hostname: string, 82 | command: string, 83 | args: string[], 84 | options?: { stdin?: string; sudoPassword?: string } 85 | ): Promise { 86 | try { 87 | const output = await this.executeCommand(hostname, command, args, options); 88 | return output; 89 | } catch (error: unknown) { 90 | if (!(error instanceof Error)) { 91 | throw error; 92 | } 93 | 94 | if (error.message.includes('Permission denied')) { 95 | const username = await this.promptForUsername(hostname); 96 | 97 | if (!username) { 98 | const msg = 'Username is required to connect to remote host'; 99 | vscode.window.showErrorMessage(msg); 100 | throw new Error(msg); 101 | } 102 | 103 | const cmdWithUser = `ssh ${username}@${hostname} ${command}`; 104 | 105 | try { 106 | const output = await this.executeCommand(hostname, command, args, options); 107 | return output; 108 | } catch (error: unknown) { 109 | if (!(error instanceof Error)) { 110 | throw error; 111 | } 112 | 113 | const message = `Authentication to ${hostname} with ${username} failed: ${error.message}`; 114 | vscode.window.showErrorMessage(message); 115 | Logger.error(message, 'ssh'); 116 | throw new Error(message); 117 | } 118 | } else { 119 | const message = `Error running command: ${error.message}`; 120 | vscode.window.showErrorMessage(message); 121 | Logger.error(message, 'ssh'); 122 | throw new Error(message); 123 | } 124 | } 125 | } 126 | 127 | public sshHostnameWithUser(hostname: string) { 128 | const { hosts } = this.configManager?.config || {}; 129 | const userForHost = hosts?.[hostname]?.user?.trim(); 130 | const defaultUser = vscode.workspace.getConfiguration('ssh').get('defaultUser')?.trim(); 131 | 132 | const user = userForHost || defaultUser; 133 | return user ? `${user}@${hostname}` : hostname; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/tailscale/analytics.ts: -------------------------------------------------------------------------------- 1 | import { trimSuffix } from '../utils/string'; 2 | 3 | const BASE_URL = 'https://tailscale.com'; 4 | const BASE_PARAMS = { 5 | utm_source: 'vscode', 6 | utm_medium: 'integration', 7 | utm_campaign: 'vscode-tailscale', 8 | }; 9 | 10 | /** 11 | * Adds UTM parameters to Tailscale URLs to track usage. 12 | */ 13 | export function track(path: string, source: string) { 14 | const queryParams = { ...BASE_PARAMS, ...(source ? { utm_content: source } : {}) }; 15 | const searchParams = new URLSearchParams(queryParams).toString(); 16 | 17 | return `${BASE_URL}/${trimSuffix(path, '/')}?${searchParams}`; 18 | } 19 | -------------------------------------------------------------------------------- /src/tailscale/cli.ts: -------------------------------------------------------------------------------- 1 | import * as cp from 'child_process'; 2 | import * as vscode from 'vscode'; 3 | import fetch from 'node-fetch'; 4 | import * as WebSocket from 'ws'; 5 | import type { ServeParams, ServeStatus, TSRelayDetails, PeersResponse } from '../types'; 6 | import { Logger } from '../logger'; 7 | import * as path from 'node:path'; 8 | import { LogLevel } from 'vscode'; 9 | import { trimSuffix } from '../utils'; 10 | import { EXTENSION_NS } from '../constants'; 11 | 12 | const LOG_COMPONENT = 'tsrelay'; 13 | 14 | export class TailscaleExecError extends Error { 15 | constructor(message: string) { 16 | super(message); 17 | this.name = 'TailscaleExecError'; 18 | } 19 | } 20 | 21 | interface vscodeModule { 22 | window: typeof vscode.window; 23 | env: typeof vscode.env; 24 | commands: typeof vscode.commands; 25 | workspace: typeof vscode.workspace; 26 | } 27 | 28 | export class Tailscale { 29 | private _vscode: vscodeModule; 30 | private nonce?: string; 31 | public url?: string; 32 | private port?: string; 33 | public authkey?: string; 34 | private childProcess?: cp.ChildProcess; 35 | private notifyExit?: () => void; 36 | private socket?: string; 37 | private ws?: WebSocket; 38 | 39 | constructor(vscode: vscodeModule) { 40 | this._vscode = vscode; 41 | } 42 | 43 | static async withInit(vscode: vscodeModule): Promise { 44 | const ts = new Tailscale(vscode); 45 | await ts.init(); 46 | vscode.workspace.onDidChangeConfiguration((event) => { 47 | if (event.affectsConfiguration('tailscale.portDiscovery.enabled')) { 48 | if (ts.portDiscoOn() && !ts.ws) { 49 | Logger.debug('running port disco'); 50 | ts.runPortDisco(); 51 | } else if (!ts.portDiscoOn() && ts.ws) { 52 | Logger.debug('turning off port disco'); 53 | ts.ws.close(); 54 | ts.ws = undefined; 55 | } 56 | } 57 | }); 58 | return ts; 59 | } 60 | 61 | defaultArgs() { 62 | const args = []; 63 | if (this._vscode.env.logLevel === LogLevel.Debug) { 64 | args.push('-v'); 65 | } 66 | if (this.port) { 67 | args.push(`-port=${this.port}`); 68 | } 69 | if (this.nonce) { 70 | args.push(`-nonce=${this.nonce}`); 71 | } 72 | if (this.socket) { 73 | args.push(`-socket=${this.socket}`); 74 | } 75 | return args; 76 | } 77 | 78 | async init() { 79 | return new Promise((resolve) => { 80 | this.socket = vscode.workspace.getConfiguration(EXTENSION_NS).get('socketPath'); 81 | let binPath = this.tsrelayPath(); 82 | let args = this.defaultArgs(); 83 | let cwd = __dirname; 84 | if (process.env.NODE_ENV === 'development') { 85 | binPath = '../tool/go'; 86 | args = ['run', '.', ...args]; 87 | cwd = path.join(cwd, '../tsrelay'); 88 | } 89 | Logger.debug(`path: ${binPath}`, LOG_COMPONENT); 90 | Logger.debug(`args: ${args.join(' ')}`, LOG_COMPONENT); 91 | 92 | this.childProcess = cp.spawn(binPath, args, { cwd: cwd }); 93 | 94 | this.childProcess.on('exit', (code) => { 95 | Logger.warn(`child process exited with code ${code}`, LOG_COMPONENT); 96 | if (this.notifyExit) { 97 | this.notifyExit(); 98 | } 99 | }); 100 | 101 | this.childProcess.on('error', (err) => { 102 | Logger.error(`child process error ${err}`, LOG_COMPONENT); 103 | }); 104 | 105 | if (this.childProcess.stdout) { 106 | this.childProcess.stdout.on('data', (data: Buffer) => { 107 | const details = JSON.parse(data.toString().trim()) as TSRelayDetails; 108 | this.url = details.address; 109 | this.nonce = details.nonce; 110 | this.port = details.port; 111 | this.authkey = Buffer.from(`${this.nonce}:`).toString('base64'); 112 | Logger.info(`url: ${this.url}`, LOG_COMPONENT); 113 | 114 | if (process.env.NODE_ENV === 'development') { 115 | Logger.info( 116 | `curl -H "Authorization: Basic ${this.authkey}" "${this.url}/serve"`, 117 | LOG_COMPONENT 118 | ); 119 | } 120 | this.runPortDisco(); 121 | resolve(null); 122 | }); 123 | } else { 124 | Logger.error('childProcess.stdout is null', LOG_COMPONENT); 125 | throw new Error('childProcess.stdout is null'); 126 | } 127 | 128 | this.processStderr(this.childProcess); 129 | }); 130 | } 131 | async initSudo() { 132 | return new Promise((resolve, err) => { 133 | const binPath = this.tsrelayPath(); 134 | const args = this.defaultArgs(); 135 | 136 | Logger.info(`path: ${binPath}`, LOG_COMPONENT); 137 | this.notifyExit = () => { 138 | Logger.info('starting sudo tsrelay'); 139 | let authCmd = `/usr/bin/pkexec`; 140 | let authArgs = ['--disable-internal-agent', binPath, ...args]; 141 | if ( 142 | process.env['container'] === 'flatpak' && 143 | process.env['FLATPAK_ID'] && 144 | process.env['FLATPAK_ID'].startsWith('com.visualstudio.code') 145 | ) { 146 | authCmd = 'flatpak-spawn'; 147 | authArgs = ['--host', 'pkexec', '--disable-internal-agent', binPath, ...args]; 148 | } 149 | const childProcess = cp.spawn(authCmd, authArgs); 150 | childProcess.on('exit', async (code) => { 151 | Logger.warn(`sudo child process exited with code ${code}`, LOG_COMPONENT); 152 | if (code === 0) { 153 | return; 154 | } else if (code === 126) { 155 | // authentication not successful 156 | this._vscode.window.showErrorMessage( 157 | 'Creating a Funnel must be done by an administrator' 158 | ); 159 | } else { 160 | this._vscode.window.showErrorMessage('Could not run authenticator, please check logs'); 161 | } 162 | await this.init(); 163 | err('unauthenticated'); 164 | }); 165 | childProcess.on('error', (err) => { 166 | Logger.error(`sudo child process error ${err}`, LOG_COMPONENT); 167 | }); 168 | childProcess.stdout.on('data', (data: Buffer) => { 169 | Logger.debug('received data from sudo'); 170 | const details = JSON.parse(data.toString().trim()) as TSRelayDetails; 171 | if (this.url !== details.address) { 172 | Logger.error(`expected url to be ${this.url} but got ${details.address}`); 173 | return; 174 | } 175 | this.runPortDisco(); 176 | Logger.debug('resolving'); 177 | resolve(null); 178 | }); 179 | this.processStderr(childProcess); 180 | }; 181 | this.dispose(); 182 | }); 183 | } 184 | 185 | portDiscoOn() { 186 | return vscode.workspace.getConfiguration(EXTENSION_NS).get('portDiscovery.enabled'); 187 | } 188 | 189 | processStderr(childProcess: cp.ChildProcess) { 190 | if (!childProcess.stderr) { 191 | Logger.error('childProcess.stderr is null', LOG_COMPONENT); 192 | throw new Error('childProcess.stderr is null'); 193 | } 194 | let buffer = ''; 195 | childProcess.stderr.on('data', (data: Buffer) => { 196 | buffer += data.toString(); // Append the data to the buffer 197 | 198 | const lines = buffer.split('\n'); // Split the buffer into lines 199 | 200 | // Process all complete lines except the last one 201 | for (let i = 0; i < lines.length - 1; i++) { 202 | const line = lines[i].trim(); 203 | if (line.length > 0) { 204 | Logger.info(line, LOG_COMPONENT); 205 | } 206 | } 207 | 208 | buffer = lines[lines.length - 1]; 209 | }); 210 | 211 | childProcess.stderr.on('end', () => { 212 | // Process the remaining data in the buffer after the stream ends 213 | const line = buffer.trim(); 214 | if (line.length > 0) { 215 | Logger.info(line, LOG_COMPONENT); 216 | } 217 | }); 218 | } 219 | 220 | tsrelayPath(): string { 221 | let arch = process.arch; 222 | let platform: string = process.platform; 223 | // See: 224 | // https://goreleaser.com/customization/builds/#why-is-there-a-_v1-suffix-on-amd64-builds 225 | if (process.arch === 'x64') { 226 | arch = 'amd64_v1'; 227 | } 228 | if (platform === 'win32') { 229 | platform = 'windows'; 230 | } 231 | return path.join(__dirname, `../bin/vscode-tailscale_${platform}_${arch}/vscode-tailscale`); 232 | } 233 | 234 | dispose() { 235 | if (this.childProcess) { 236 | Logger.info('shutting down tsrelay'); 237 | this.childProcess.kill(); 238 | } 239 | } 240 | 241 | async serveStatus(): Promise { 242 | if (!this.url) { 243 | throw new Error('uninitialized client'); 244 | } 245 | try { 246 | const resp = await fetch(`${this.url}/serve`, { 247 | headers: { 248 | Authorization: 'Basic ' + this.authkey, 249 | }, 250 | }); 251 | 252 | const status = (await resp.json()) as ServeStatus; 253 | return status; 254 | } catch (e) { 255 | Logger.error(`error calling serve: ${JSON.stringify(e, null, 2)}`); 256 | throw e; 257 | } 258 | } 259 | 260 | async getPeers(): Promise { 261 | if (!this.url) { 262 | throw new Error('uninitialized client'); 263 | } 264 | try { 265 | const resp = await fetch(`${this.url}/peers`, { 266 | headers: { 267 | Authorization: 'Basic ' + this.authkey, 268 | }, 269 | }); 270 | const status = (await resp.json()) as PeersResponse; 271 | return status; 272 | } catch (e) { 273 | Logger.error(`error calling serve: ${JSON.stringify(e, null, 2)}`); 274 | throw e; 275 | } 276 | } 277 | 278 | async serveAdd(p: ServeParams) { 279 | if (!this.url) { 280 | throw new Error('uninitialized client'); 281 | } 282 | try { 283 | const resp = await fetch(`${this.url}/serve`, { 284 | method: 'POST', 285 | headers: { 286 | Authorization: 'Basic ' + this.authkey, 287 | }, 288 | body: JSON.stringify(p), 289 | }); 290 | if (!resp.ok) { 291 | throw new Error('/serve failed'); 292 | } 293 | } catch (e) { 294 | Logger.info(`error adding serve: ${e}`); 295 | throw e; 296 | } 297 | } 298 | 299 | async serveDelete(p?: ServeParams) { 300 | if (!this.url) { 301 | throw new Error('uninitialized client'); 302 | } 303 | try { 304 | const resp = await fetch(`${this.url}/serve`, { 305 | method: 'DELETE', 306 | headers: { 307 | Authorization: 'Basic ' + this.authkey, 308 | }, 309 | body: JSON.stringify(p), 310 | }); 311 | if (!resp.ok) { 312 | throw new Error('/serve failed'); 313 | } 314 | } catch (e) { 315 | Logger.info(`error deleting serve: ${e}`); 316 | throw e; 317 | } 318 | } 319 | 320 | async setFunnel(port: number, on: boolean) { 321 | if (!this.url) { 322 | throw new Error('uninitialized client'); 323 | } 324 | try { 325 | const resp = await fetch(`${this.url}/funnel`, { 326 | method: 'POST', 327 | headers: { 328 | Authorization: 'Basic ' + this.authkey, 329 | }, 330 | body: JSON.stringify({ port, on }), 331 | }); 332 | if (!resp.ok) { 333 | throw new Error('/serve failed'); 334 | } 335 | } catch (e) { 336 | Logger.info(`error deleting serve: ${e}`); 337 | throw e; 338 | } 339 | } 340 | 341 | runPortDisco() { 342 | if (!this.url) { 343 | throw new Error('uninitialized client'); 344 | } 345 | if (!this.portDiscoOn()) { 346 | Logger.info('port discovery is off'); 347 | return; 348 | } 349 | 350 | this.ws = new WebSocket(`ws://${this.url.slice('http://'.length)}/portdisco`, { 351 | headers: { 352 | Authorization: 'Basic ' + this.authkey, 353 | }, 354 | }); 355 | this.ws.on('error', (e) => { 356 | Logger.info(`got ws error: ${e}`); 357 | }); 358 | this.ws.on('open', () => { 359 | Logger.info('websocket is open'); 360 | this._vscode.window.terminals.forEach(async (t) => { 361 | const pid = await t.processId; 362 | if (!pid) { 363 | return; 364 | } 365 | Logger.debug(`adding initial termianl process: ${pid}`); 366 | this.ws?.send( 367 | JSON.stringify({ 368 | type: 'addPID', 369 | pid: pid, 370 | }) 371 | ); 372 | }); 373 | }); 374 | this.ws.on('close', () => { 375 | Logger.info('websocket is closed'); 376 | }); 377 | this.ws.on('message', async (data) => { 378 | Logger.info('got message'); 379 | const msg = JSON.parse(data.toString()); 380 | Logger.info(`msg is ${msg.type}`); 381 | if (msg.type != 'newPort') { 382 | return; 383 | } 384 | const shouldServe = await this._vscode.window.showInformationMessage( 385 | msg.message, 386 | { modal: false }, 387 | 'Serve' 388 | ); 389 | if (shouldServe) { 390 | await this.runFunnel(msg.port); 391 | } 392 | }); 393 | this._vscode.window.onDidOpenTerminal(async (e: vscode.Terminal) => { 394 | Logger.info('terminal opened'); 395 | const pid = await e.processId; 396 | if (!pid) { 397 | return; 398 | } 399 | Logger.info(`pid is ${pid}`); 400 | this.ws?.send( 401 | JSON.stringify({ 402 | type: 'addPID', 403 | pid: pid, 404 | }) 405 | ); 406 | Logger.info('pid sent'); 407 | }); 408 | this._vscode.window.onDidCloseTerminal(async (e: vscode.Terminal) => { 409 | const pid = await e.processId; 410 | if (!pid) { 411 | return; 412 | } 413 | this.ws?.send( 414 | JSON.stringify({ 415 | type: 'removePID', 416 | pid: pid, 417 | }) 418 | ); 419 | }); 420 | } 421 | 422 | async runFunnel(port: number) { 423 | await this.serveAdd({ 424 | protocol: 'https', 425 | port: 443, 426 | mountPoint: '/', 427 | source: `http://127.0.0.1:${port}`, 428 | funnel: true, 429 | }); 430 | 431 | const selection = await this._vscode.window.showInformationMessage( 432 | `Port ${port} shared over Tailscale`, 433 | 'Copy URL' 434 | ); 435 | if (selection === 'Copy URL') { 436 | const status = await this.serveStatus(); 437 | const hostname = trimSuffix(status.Self?.DNSName, '.'); 438 | this._vscode.env.clipboard.writeText(`https://${hostname}`); 439 | } 440 | 441 | await this._vscode.commands.executeCommand('serve-view.refresh'); 442 | } 443 | } 444 | -------------------------------------------------------------------------------- /src/tailscale/error.ts: -------------------------------------------------------------------------------- 1 | interface TailscaleError { 2 | title: string; 3 | message: string; 4 | links?: Link[]; 5 | } 6 | 7 | interface Link { 8 | url: string; 9 | title: string; 10 | } 11 | 12 | export function errorForType(type: string): TailscaleError { 13 | switch (type) { 14 | case 'OFFLINE': 15 | return { 16 | title: 'Tailscale offline', 17 | message: 'Please log in and try again', 18 | }; 19 | case 'NOT_RUNNING': 20 | return { 21 | title: 'Tailscale not running', 22 | message: 'Tailscale is either uninstalled or not running', 23 | links: [{ url: 'https://tailscale.com/download', title: 'Install' }], 24 | }; 25 | case 'FUNNEL_OFF': 26 | return { 27 | title: 'Funnel is disabled', 28 | message: 29 | 'Enable Funnel by adding a new `funnel` attribute under `noteAttrs` in your tailnet policy file.', 30 | links: [ 31 | { url: 'https://tailscale.com/kb/1223/tailscale-funnel/#setup', title: 'Enable Funnel' }, 32 | ], 33 | }; 34 | case 'HTTPS_OFF': 35 | return { 36 | title: 'HTTPS disabled', 37 | message: 38 | 'HTTPS is required to use Funnel. Enable the HTTPS certificates in the Admin Console.', 39 | links: [ 40 | { 41 | url: 'https://tailscale.com/kb/1153/enabling-https/#configure-https', 42 | title: 'Enable HTTPS', 43 | }, 44 | ], 45 | }; 46 | case 'FLATPAK_REQUIRES_RESTART': 47 | return { 48 | title: 'Restart Flatpak Container', 49 | message: 'Please quit VSCode and restart the container to finish setting up Tailscale', 50 | }; 51 | default: 52 | return { 53 | title: 'Unknown error', 54 | message: 'An unknown error occurred. Check the logs for more information or file an issue.', 55 | }; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/tailscale/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cli'; 2 | export * from './analytics'; 3 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | interface ServeHTTPSParams { 2 | protocol: 'https'; 3 | port: number; 4 | mountPoint: string; 5 | source: string; 6 | funnel?: boolean; 7 | } 8 | 9 | interface ServeTCPParams { 10 | protocol: 'tcp' | 'tls-terminated-tcp'; 11 | port: number; 12 | localPort: string; 13 | funnel?: boolean; 14 | } 15 | 16 | export type ServeParams = ServeHTTPSParams | ServeTCPParams; 17 | 18 | export interface Handlers { 19 | Proxy: string; 20 | } 21 | 22 | export interface Peer { 23 | ID: string; 24 | ServerName: string; 25 | HostName: string; 26 | Active?: boolean; 27 | IsExternal: boolean; 28 | Online?: boolean; 29 | TailscaleIPs: string[]; 30 | ShareeNode?: boolean; 31 | DNSName: string; 32 | SSHEnabled: boolean; 33 | Address: string; 34 | } 35 | 36 | export interface CurrentTailnet { 37 | Name: string; 38 | MagicDNSEnabled: boolean; 39 | MagicDNSSuffix?: string; 40 | } 41 | 42 | export interface PeersResponse extends WithErrors { 43 | CurrentTailnet: CurrentTailnet; 44 | PeerGroups: PeerGroup[]; 45 | } 46 | 47 | export interface PeerGroup { 48 | Name: string; 49 | Peers: Peer[]; 50 | } 51 | 52 | export interface ServeStatus extends WithErrors { 53 | ServeConfig?: ServeConfig; 54 | FunnelPorts?: number[]; 55 | Services: { 56 | [port: number]: string; 57 | }; 58 | BackendState: string; 59 | // TODO: Self is optional in the API, which might not be 60 | // correct. We need to settle on it being optional or always present. 61 | Self: PeerStatus; 62 | } 63 | 64 | export interface WithErrors { 65 | Errors?: RelayError[]; 66 | } 67 | 68 | export interface RelayError { 69 | Type: 70 | | 'FUNNEL_OFF' 71 | | 'HTTPS_OFF' 72 | | 'OFFLINE' 73 | | 'REQUIRES_SUDO' 74 | | 'NOT_RUNNING' 75 | | 'FLATPAK_REQUIRES_RESTART'; 76 | } 77 | 78 | interface PeerStatus { 79 | DNSName: string; 80 | Online: boolean; 81 | } 82 | 83 | export interface ServeConfig { 84 | TCP?: { 85 | [port: number]: { 86 | HTTPS: boolean; 87 | }; 88 | }; 89 | Web?: { 90 | [address: string]: { 91 | Handlers: Handlers; 92 | }; 93 | }; 94 | AllowFunnel?: { 95 | [address: string]: boolean; 96 | }; 97 | Self?: { 98 | DNSName: string; 99 | }; 100 | } 101 | 102 | export interface Version { 103 | majorMinorPatch: string; 104 | short: string; 105 | long: string; 106 | gitCommit: string; 107 | extraGitCommit: string; 108 | cap: number; 109 | } 110 | 111 | /** 112 | * Messages sent from the webview to the extension. 113 | */ 114 | 115 | interface RefreshState { 116 | type: 'refreshState'; 117 | } 118 | 119 | interface DeleteServe { 120 | type: 'deleteServe'; 121 | params: ServeParams; 122 | } 123 | 124 | interface AddServe { 125 | type: 'addServe'; 126 | params: ServeParams; 127 | } 128 | 129 | interface ResetServe { 130 | type: 'resetServe'; 131 | } 132 | 133 | interface SetFunnel { 134 | type: 'setFunnel'; 135 | params: { 136 | port: string; 137 | allow: boolean; 138 | }; 139 | } 140 | 141 | interface WriteToClipboard { 142 | type: 'writeToClipboard'; 143 | params: { 144 | text: string; 145 | }; 146 | } 147 | 148 | interface OpenLink { 149 | type: 'openLink'; 150 | params: { 151 | url: string; 152 | }; 153 | } 154 | 155 | export type Message = 156 | | RefreshState 157 | | DeleteServe 158 | | AddServe 159 | | ResetServe 160 | | SetFunnel 161 | | WriteToClipboard 162 | | OpenLink 163 | | SudoPrompt; 164 | 165 | interface SudoPrompt { 166 | type: 'sudoPrompt'; 167 | operation: 'add' | 'delete'; 168 | params?: ServeParams; 169 | } 170 | 171 | /** 172 | * Messages sent from the extension to the webview. 173 | */ 174 | 175 | interface UpdateState { 176 | type: 'updateState'; 177 | state: ServeConfig; 178 | } 179 | 180 | interface RefreshState { 181 | type: 'refreshState'; 182 | } 183 | 184 | interface WebpackOk { 185 | type: 'webpackOk'; 186 | } 187 | 188 | interface WebpackInvalid { 189 | type: 'webpackInvalid'; 190 | } 191 | 192 | interface WebpackStillOk { 193 | type: 'webpackStillOk'; 194 | } 195 | 196 | export type WebviewData = UpdateState | RefreshState | WebpackOk | WebpackInvalid | WebpackStillOk; 197 | export type WebviewEvent = Event & { data: WebviewData }; 198 | 199 | export interface NewPortNotification { 200 | message: string; 201 | port: number; 202 | } 203 | 204 | export interface TSRelayDetails { 205 | address: string; 206 | nonce: string; 207 | port: string; 208 | } 209 | 210 | export interface FileInfo { 211 | name: string; 212 | isDir: boolean; 213 | path: string; 214 | } 215 | -------------------------------------------------------------------------------- /src/utils/error.ts: -------------------------------------------------------------------------------- 1 | type ErrorWithMessage = { 2 | message: string; 3 | }; 4 | 5 | function isErrorWithMessage(error: unknown): error is ErrorWithMessage { 6 | return ( 7 | typeof error === 'object' && 8 | error !== null && 9 | 'message' in error && 10 | typeof (error as Record).message === 'string' 11 | ); 12 | } 13 | 14 | function toErrorWithMessage(maybeError: unknown): ErrorWithMessage { 15 | if (isErrorWithMessage(maybeError)) return maybeError; 16 | 17 | try { 18 | return new Error(JSON.stringify(maybeError)); 19 | } catch { 20 | return new Error(String(maybeError)); 21 | } 22 | } 23 | 24 | export function getErrorMessage(error: unknown) { 25 | return toErrorWithMessage(error).message; 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/host.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { userInfo } from 'os'; 3 | import { ConfigManager } from '../config-manager'; 4 | 5 | export function getUsername(configManager: ConfigManager, hostname: string) { 6 | const { hosts } = configManager?.config || {}; 7 | const userForHost = hosts?.[hostname]?.user?.trim(); 8 | const defaultUser = vscode.workspace 9 | .getConfiguration('tailscale') 10 | .get('ssh.defaultUser') 11 | ?.trim(); 12 | 13 | return userForHost || defaultUser || userInfo().username; 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './string'; 2 | -------------------------------------------------------------------------------- /src/utils/sshconfig.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as SSHConfig from 'ssh-config'; 3 | import * as os from 'os'; 4 | import { ConfigManager } from '../config-manager'; 5 | import { getUsername } from './host'; 6 | 7 | function sshConfigFilePath() { 8 | const filePath = vscode.workspace.getConfiguration('remote').get('SSH.configFile'); 9 | return filePath 10 | ? vscode.Uri.file(filePath) 11 | : vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), './.ssh/config'); 12 | } 13 | 14 | async function readSSHConfig() { 15 | const configStr = await vscode.workspace.fs 16 | .readFile(sshConfigFilePath()) 17 | .then((a) => Buffer.from(a).toString('utf-8')); 18 | 19 | const config = SSHConfig.parse(configStr); 20 | 21 | const hosts = config 22 | // find all the hosts 23 | .filter((line): line is SSHConfig.Directive => { 24 | return (line as SSHConfig.Directive).param === 'Host'; 25 | }) 26 | // get all the host names 27 | .flatMap((hostDirective) => hostDirective.value) 28 | // get their effective computed option values 29 | // (this is necessary because a host might have multiple matching Host blocks, 30 | // and the effective options are computed by combining all of them) 31 | .map((h) => config.compute(h)); 32 | 33 | return { config, hosts }; 34 | } 35 | 36 | export async function addToSSHConfig(configManager: ConfigManager, HostName: string, User: string) { 37 | const { config, hosts } = await readSSHConfig(); 38 | const matchingHosts = hosts.filter((h) => h.HostName === HostName); 39 | if (matchingHosts.length === 0) { 40 | config.append({ Host: HostName, User, HostName }); 41 | } else { 42 | const h = matchingHosts[0]; 43 | const cfgHost = typeof h.Host === 'string' ? h.Host : h.Host[0]; 44 | const section = config.find({ Host: cfgHost }); 45 | if (section && 'config' in section) { 46 | let added = false; 47 | for (const line of section.config) { 48 | if (line.type === SSHConfig.LineType.DIRECTIVE && line.param === 'User') { 49 | line.value = User; 50 | added = true; 51 | break; 52 | } 53 | } 54 | if (!added) { 55 | section.config.append({ User }); 56 | } 57 | } 58 | } 59 | await vscode.workspace.fs.writeFile( 60 | sshConfigFilePath(), 61 | Buffer.from(SSHConfig.stringify(config)) 62 | ); 63 | configManager.setForHost(HostName, 'persistToSSHConfig', true); 64 | configManager.setForHost(HostName, 'differentUserFromSSHConfig', false); 65 | } 66 | 67 | export async function syncSSHConfig(addr: string, configManager: ConfigManager) { 68 | const { config, hosts } = await readSSHConfig(); 69 | 70 | const matchingHosts = hosts.filter((h) => h.HostName === addr); 71 | const tsUsername = getUsername(configManager, addr); 72 | if (matchingHosts.length === 0) { 73 | const add = await vscode.window.showInformationMessage( 74 | `Host ${addr} not found in SSH config file, would you like to add it?`, 75 | 'Yes', 76 | 'No' 77 | ); 78 | if (add === 'Yes') { 79 | await addToSSHConfig(configManager, addr, tsUsername); 80 | } else { 81 | configManager.setForHost(addr, 'persistToSSHConfig', false); 82 | } 83 | } else if (!configManager.config.hosts?.[addr].differentUserFromSSHConfig) { 84 | for (const h of matchingHosts) { 85 | const cfgUsername = typeof h.User === 'string' ? h.User : h.User[0]; 86 | const cfgHost = typeof h.Host === 'string' ? h.Host : h.Host[0]; 87 | if (cfgUsername !== tsUsername) { 88 | const editHost = await vscode.window.showInformationMessage( 89 | `The SSH config file specifies a username (${cfgUsername}) for host ${addr} that 90 | is different from the SSH user configured in the Tailscale extension (${tsUsername}). Would you 91 | like to update one of them?`, 92 | 'Update extension', 93 | 'Update SSH config', 94 | 'Do nothing' 95 | ); 96 | if (editHost === 'Update extension') { 97 | configManager.setForHost(addr, 'user', cfgUsername); 98 | configManager.setForHost(addr, 'differentUserFromSSHConfig', false); 99 | } else if (editHost === 'Update SSH config') { 100 | const section = config.find({ Host: cfgHost }); 101 | if (section && 'config' in section) { 102 | for (const line of section.config) { 103 | if (line.type === SSHConfig.LineType.DIRECTIVE && line.param === 'User') { 104 | line.value = tsUsername; 105 | break; 106 | } 107 | } 108 | await vscode.workspace.fs.writeFile( 109 | sshConfigFilePath(), 110 | Buffer.from(SSHConfig.stringify(config)) 111 | ); 112 | configManager.setForHost(addr, 'differentUserFromSSHConfig', false); 113 | } 114 | } else { 115 | configManager.setForHost(addr, 'differentUserFromSSHConfig', true); 116 | } 117 | } 118 | // according to man ssh_config, ssh uses the first matching entry, so we can break here 119 | break; 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/utils/string.test.ts: -------------------------------------------------------------------------------- 1 | import { test, describe, expect } from 'vitest'; 2 | import { escapeSpace, trimSuffix } from './string'; 3 | 4 | describe('escapeSpace', () => { 5 | test('escapes spaces', () => { 6 | const result = escapeSpace('foo bar'); 7 | expect(result).toEqual('foo\\ bar'); 8 | }); 9 | 10 | test('does not escape other characters', () => { 11 | const result = escapeSpace('foo-bar'); 12 | expect(result).toEqual('foo-bar'); 13 | }); 14 | }); 15 | 16 | describe('trimSuffix', () => { 17 | test('trims the suffix', () => { 18 | const result = trimSuffix('foo.bar', '.bar'); 19 | expect(result).toEqual('foo'); 20 | }); 21 | 22 | test('does not trim the suffix if it does not match', () => { 23 | const result = trimSuffix('foo.bar', '.baz'); 24 | expect(result).toEqual('foo.bar'); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/utils/string.ts: -------------------------------------------------------------------------------- 1 | export function trimSuffix(str: string | undefined, suffix: string) { 2 | if (!str) { 3 | return; 4 | } 5 | 6 | return str.endsWith(suffix) ? str.slice(0, -suffix.length) : str; 7 | } 8 | 9 | export function escapeSpace(str: string): string { 10 | return str.replace(/\s/g, '\\ '); 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/uri.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, describe, vi } from 'vitest'; 2 | import { createTsUri, parseTsUri } from './uri'; 3 | import { URI, Utils } from 'vscode-uri'; 4 | 5 | vi.mock('vscode', async () => { 6 | return { 7 | Uri: { 8 | parse: (uri: string) => URI.parse(uri), 9 | from: (params: { scheme: string; authority: string; path: string }) => URI.from(params), 10 | joinPath: (uri: URI, ...paths: string[]) => Utils.joinPath(uri, ...paths), 11 | }, 12 | }; 13 | }); 14 | 15 | describe('parseTsUri', () => { 16 | test('parses ts URIs correctly', () => { 17 | const testUri = URI.parse('ts://tails-scales/foo/home/amalie'); 18 | const expected = { 19 | address: 'foo', 20 | tailnet: 'tails-scales', 21 | resourcePath: '/home/amalie', 22 | }; 23 | 24 | const result = parseTsUri(testUri); 25 | expect(result).toEqual(expected); 26 | }); 27 | 28 | test('throws an error when scheme is not supported', () => { 29 | const testUri = URI.parse('http://example.com'); 30 | 31 | expect(() => parseTsUri(testUri)).toThrow('Unsupported scheme: http'); 32 | }); 33 | 34 | test('correctly returns ~ as a resourcePath', () => { 35 | const testUri = URI.parse('ts://tails-scales/foo/~'); 36 | const expected = { 37 | address: 'foo', 38 | tailnet: 'tails-scales', 39 | resourcePath: '.', 40 | }; 41 | 42 | const result = parseTsUri(testUri); 43 | expect(result).toEqual(expected); 44 | }); 45 | 46 | test('correctly returns ~ in a deeply nested resourcePath', () => { 47 | const testUri = URI.parse('ts://tails-scales/foo/~/bar/baz'); 48 | const expected = { 49 | address: 'foo', 50 | tailnet: 'tails-scales', 51 | resourcePath: './bar/baz', 52 | }; 53 | 54 | const result = parseTsUri(testUri); 55 | expect(result).toEqual(expected); 56 | }); 57 | }); 58 | 59 | describe('createTsUri', () => { 60 | test('creates ts URIs correctly', () => { 61 | const expected = URI.parse('ts://tails-scales/foo/home/amalie'); 62 | const params = { 63 | address: 'foo', 64 | tailnet: 'tails-scales', 65 | resourcePath: '/home/amalie', 66 | }; 67 | 68 | expect(createTsUri(params)).toEqual(expected); 69 | }); 70 | 71 | test('creates ts URIs correctly', () => { 72 | const expected = URI.parse('ts://tails-scales/foo/~'); 73 | const params = { 74 | address: 'foo', 75 | tailnet: 'tails-scales', 76 | resourcePath: '~', 77 | }; 78 | 79 | expect(createTsUri(params)).toEqual(expected); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/utils/uri.ts: -------------------------------------------------------------------------------- 1 | import { Uri } from 'vscode'; 2 | 3 | export interface TsUri { 4 | address: string; 5 | tailnet: string; 6 | resourcePath: string; 7 | } 8 | 9 | /** 10 | * 11 | * ts://tails-scales/foo/home/amalie 12 | * |> tailnet: tails-scales 13 | * |> hostname: foo 14 | * |> resourcePath: /home/amalie 15 | */ 16 | 17 | export function parseTsUri(uri: Uri): TsUri { 18 | switch (uri.scheme) { 19 | case 'ts': { 20 | let hostPath = uri.path; 21 | if (hostPath.startsWith('/')) { 22 | // Remove leading slash 23 | hostPath = hostPath.slice(1); 24 | } 25 | 26 | const segments = hostPath.split('/'); 27 | const [address, ...pathSegments] = segments; 28 | 29 | if (pathSegments[0] === '~') { 30 | pathSegments[0] = '.'; 31 | } 32 | 33 | let resourcePath = decodeURIComponent(pathSegments.join('/')); 34 | 35 | if (!resourcePath.startsWith('.')) { 36 | resourcePath = `/${resourcePath}`; 37 | } 38 | 39 | return { address, tailnet: uri.authority, resourcePath }; 40 | } 41 | default: 42 | throw new Error(`Unsupported scheme: ${uri.scheme}`); 43 | } 44 | } 45 | 46 | interface TsUriParams { 47 | tailnet: string; 48 | address: string; 49 | resourcePath: string; 50 | } 51 | 52 | export function createTsUri({ tailnet, address, resourcePath }: TsUriParams): Uri { 53 | return Uri.joinPath( 54 | Uri.from({ scheme: 'ts', authority: tailnet, path: '/' }), 55 | address, 56 | ...resourcePath.split('/') 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/utils/url.ts: -------------------------------------------------------------------------------- 1 | export const ADMIN_CONSOLE = 'https://login.tailscale.com'; 2 | export const ADMIN_CONSOLE_DNS = `${ADMIN_CONSOLE}/admin/dns`; 3 | export const KB_FUNNEL_SETUP = 'https://tailscale.com/kb/1223/tailscale-funnel/#setup'; 4 | export const KB_FUNNEL_USE_CASES = 'https://tailscale.com/kb/1247/funnel-serve-use-cases'; 5 | export const KB_ENABLE_HTTPS = 'https://tailscale.com/kb/1153/enabling-https/#configure-https'; 6 | export const KB_DOCS_URL = 'https://tailscale.com/kb/1265/vscode-extension/'; 7 | -------------------------------------------------------------------------------- /src/vscode-api.ts: -------------------------------------------------------------------------------- 1 | import { WebviewApi } from 'vscode-webview'; 2 | import type { Message } from './types'; 3 | 4 | class VSCodeWrapper { 5 | readonly vscodeApi: WebviewApi = acquireVsCodeApi(); 6 | 7 | public postMessage(message: Message): void { 8 | this.vscodeApi.postMessage(message); 9 | } 10 | 11 | public writeToClipboard(text: string): void { 12 | this.postMessage({ 13 | type: 'writeToClipboard', 14 | params: { 15 | text, 16 | }, 17 | }); 18 | } 19 | 20 | public openLink(url: string): void { 21 | this.postMessage({ 22 | type: 'openLink', 23 | params: { 24 | url, 25 | }, 26 | }); 27 | } 28 | } 29 | 30 | // Singleton to prevent multiple fetches of VsCodeAPI. 31 | export const vsCodeAPI: VSCodeWrapper = new VSCodeWrapper(); 32 | -------------------------------------------------------------------------------- /src/webviews/serve-panel/app.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { SimpleView } from './simple-view'; 4 | 5 | export const App = () => { 6 | return ; 7 | }; 8 | -------------------------------------------------------------------------------- /src/webviews/serve-panel/components/error.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import { vsCodeAPI } from '../../../vscode-api'; 3 | import { VSCodeButton } from '@vscode/webview-ui-toolkit/react'; 4 | import { errorForType } from '../../../tailscale/error'; 5 | 6 | export const Error = ({ type }) => { 7 | const { title, links, message } = errorForType(type); 8 | 9 | return ( 10 |
11 |
12 |
13 | {title &&
{title}
} 14 |
{message}
15 | {links && ( 16 |
17 | {links.map(({ title, url }) => ( 18 | vsCodeAPI.openLink(url)}> 19 | {title} 20 | 21 | ))} 22 |
23 | )} 24 |
25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/webviews/serve-panel/components/path-input.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | export function PathInput({ 4 | value = '', 5 | placeholder = '/', 6 | minWidth = false, 7 | ...rest 8 | }: { 9 | value?: string; 10 | placeholder?: string; 11 | minWidth?: boolean; 12 | [key: string]: unknown; 13 | }) { 14 | const [path, setPath] = useState(value); 15 | 16 | function style() { 17 | if (!minWidth) { 18 | return {}; 19 | } 20 | 21 | if (path) { 22 | return { width: `${path.length}ch` }; 23 | } 24 | 25 | if (placeholder) { 26 | return { width: `${placeholder?.length - 1}ch` }; 27 | } 28 | } 29 | 30 | function onInput(e: React.FormEvent) { 31 | const p = e.currentTarget.value; 32 | 33 | if (p === '') { 34 | setPath(undefined); 35 | return; 36 | } 37 | 38 | // TODO(all): filter/validate on https://datatracker.ietf.org/doc/html/rfc3986#section-2 39 | setPath(p.startsWith('/') ? p : `/${p}`); 40 | } 41 | 42 | return ( 43 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/webviews/serve-panel/components/port-input.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, InputHTMLAttributes, useState, useEffect, useRef } from 'react'; 2 | 3 | interface PortInputProps extends InputHTMLAttributes { 4 | value?: string; 5 | placeholder?: string; 6 | minWidth?: boolean; 7 | width?: number; 8 | className?: string; 9 | style?: CSSProperties; 10 | } 11 | 12 | export function PortInput({ 13 | placeholder = '', 14 | defaultValue = '', 15 | minWidth = false, 16 | ...props 17 | }: PortInputProps) { 18 | const [port, setPort] = useState(defaultValue); 19 | 20 | useEffect(() => { 21 | setPort(defaultValue); 22 | }, [defaultValue]); 23 | 24 | const inputRef = useRef(null); 25 | 26 | useEffect(() => { 27 | if (inputRef.current) { 28 | inputRef.current.focus(); 29 | } 30 | }, []); 31 | 32 | const handleInput = (e: React.ChangeEvent) => { 33 | const inputValue = e.target.value.replace(/[^0-9]/g, ''); 34 | 35 | // limit to valid TCP/UDP ports 36 | if (Number(inputValue) > 65535) { 37 | return; 38 | } 39 | 40 | setPort(inputValue); 41 | 42 | if (props.onInput) { 43 | const newEvent = { ...e, target: { ...e.target, value: inputValue } }; 44 | props.onInput(newEvent); 45 | } 46 | }; 47 | 48 | return ( 49 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/webviews/serve-panel/components/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | export const Tooltip = ({ children, tip }) => { 4 | const [showTip, setTip] = useState(false); 5 | 6 | const toggleTip = () => { 7 | setTip(!showTip); 8 | }; 9 | 10 | return ( 11 |
12 | {children} 13 | {tip && showTip && ( 14 |
15 | {tip} 16 |
17 | )} 18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/webviews/serve-panel/data.tsx: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr'; 2 | import useSWRMutation from 'swr/mutation'; 3 | import { ServeStatus, ServeParams } from '../../types'; 4 | 5 | export function useServe() { 6 | // TODO(tyler): implement cache provider using memento storage (context.globalstate) 7 | return useSWR('/serve', fetchWithUser, { refreshInterval: 3000 }); 8 | } 9 | 10 | export function useServeMutation() { 11 | return useSWRMutation('/serve', (path: string, { arg }: { arg?: ServeParams }) => { 12 | const requestOptions: RequestInit = { 13 | method: 'POST', 14 | body: arg ? JSON.stringify(arg) : undefined, 15 | }; 16 | 17 | return fetchWithUser(path, requestOptions); 18 | }); 19 | } 20 | 21 | export async function fetchWithUser(path: string, options: RequestInit = {}) { 22 | const { url, authkey } = window.tailscale; 23 | 24 | options.headers = options.headers || {}; 25 | options.headers['Content-Type'] = 'application/json'; 26 | options.headers['Authorization'] = `Basic ${authkey}`; 27 | 28 | console.time(path); 29 | const res = await fetch(url + path, options); 30 | console.timeEnd(path); 31 | return res.json(); 32 | } 33 | -------------------------------------------------------------------------------- /src/webviews/serve-panel/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @import '~@vscode/codicons/dist/codicon.css'; 6 | 7 | .tailscale-serve input { 8 | background-color: var(--vscode-editor-background); 9 | } 10 | 11 | .tailscale-serve vscode-data-grid-row:hover input { 12 | background-color: var(--vscode-list-hoverBackground); 13 | } 14 | 15 | vscode-data-grid-cell { 16 | line-height: calc(var(--input-height) * 1px); 17 | } 18 | -------------------------------------------------------------------------------- /src/webviews/serve-panel/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { provideVSCodeDesignSystem, vsCodeButton } from '@vscode/webview-ui-toolkit'; 4 | import { App } from './app'; 5 | import type { WebviewEvent } from '../../types'; 6 | 7 | provideVSCodeDesignSystem().register(vsCodeButton()); 8 | 9 | import './index.css'; 10 | 11 | window.addEventListener('message', (m: WebviewEvent) => { 12 | switch (m.data.type) { 13 | // ignored dev messages 14 | case 'webpackOk': 15 | case 'webpackInvalid': 16 | case 'webpackStillOk': 17 | break; 18 | 19 | default: 20 | console.log('Unknown message type', m); 21 | } 22 | }); 23 | 24 | if (module.hot) { 25 | module.hot.accept(); 26 | } 27 | 28 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 29 | const root = createRoot(document.getElementById('root')!); 30 | root.render(); 31 | -------------------------------------------------------------------------------- /src/webviews/serve-panel/simple-view.tsx: -------------------------------------------------------------------------------- 1 | import React, { FormEvent, useEffect, useState } from 'react'; 2 | import { VSCodeButton, VSCodeLink, VSCodeProgressRing } from '@vscode/webview-ui-toolkit/react'; 3 | import { trimSuffix } from '../../utils/string'; 4 | import { vsCodeAPI } from '../../vscode-api'; 5 | import { PortInput } from './components/port-input'; 6 | import { Error } from './components/error'; 7 | import { KB_FUNNEL_USE_CASES } from '../../utils/url'; 8 | import { useServe, useServeMutation, fetchWithUser } from './data'; 9 | import { Tooltip } from './components/tooltip'; 10 | import { errorForType } from '../../tailscale/error'; 11 | import { ServeParams, WithErrors } from '../../types'; 12 | 13 | export const SimpleView = () => { 14 | const { data, mutate, isLoading } = useServe(); 15 | const { trigger, isMutating } = useServeMutation(); 16 | const [isDeleting, setIsDeleting] = useState(false); 17 | const [port, setPort] = useState(''); 18 | const [previousPort, setPreviousPort] = useState(''); 19 | const [disabledText, setDisabledText] = useState(undefined); 20 | 21 | const DNSName = trimSuffix(data?.Self?.DNSName, '.'); 22 | const persistedPort = 23 | data?.ServeConfig?.Web?.[`${DNSName}:443`]?.Handlers['/']?.Proxy.split(':')[2]; 24 | 25 | useEffect(() => { 26 | if (data?.Errors && data.Errors.length > 0) { 27 | const e = errorForType(data.Errors[0].Type); 28 | setDisabledText(e.title); 29 | return; 30 | } 31 | 32 | setDisabledText(undefined); 33 | }, [data]); 34 | 35 | useEffect(() => { 36 | setPort(persistedPort); 37 | }, [persistedPort]); 38 | 39 | useEffect(() => { 40 | const handleMessage = (event) => { 41 | const message = event.data; // The JSON data our extension sent 42 | 43 | switch (message.command) { 44 | case 'refreshState': 45 | mutate(); 46 | break; 47 | } 48 | }; 49 | 50 | window.addEventListener('message', handleMessage); 51 | 52 | return () => { 53 | window.removeEventListener('message', handleMessage); 54 | }; 55 | }, [mutate]); 56 | 57 | if (isLoading) { 58 | return ; 59 | } 60 | 61 | const textStyle = 'text-bannerForeground bg-bannerBackground'; 62 | const textDisabledStyle = 'text-foreground bg-background'; 63 | const hasServeTextStyle = persistedPort ? textStyle : textDisabledStyle; 64 | return ( 65 |
66 | {data?.Errors?.map((error, index) => )} 67 | 68 |
69 |
Tailscale Funnel
70 |
71 | Share a local server on the internet and more with Funnel.{' '} 72 | Learn More 73 |
74 |
75 | 76 | {data?.Self &&
} 77 |
78 | ); 79 | 80 | function Form() { 81 | const handlePortChange = (e) => { 82 | setPort(e.target.value); 83 | }; 84 | 85 | return ( 86 | 87 |
88 |
89 | 94 | https://{DNSName} 95 | 96 | persistedPort && vsCodeAPI.openLink(`https://${DNSName}`)} 98 | className={`${ 99 | persistedPort ? 'cursor-pointer' : '' 100 | } codicon codicon-globe pl-2 ml-auto`} 101 | > 102 | 103 | 104 | persistedPort && vsCodeAPI.writeToClipboard(`https://${DNSName}`)} 106 | className={`${persistedPort ? 'cursor-pointer' : ''} codicon codicon-copy pl-1`} 107 | > 108 | 109 |
110 | 111 |
112 |
113 |
114 | 115 |
116 |
117 |
118 |
119 |
120 | 121 |
122 | http://127.0.0.1: 123 | 124 |
125 | 133 |
134 |
135 | 136 | {(persistedPort && port === persistedPort) || port === '' ? ( 137 | 138 | 143 | {isDeleting ? 'Stoping' : 'Stop'} 144 | 145 | 146 | ) : ( 147 | 148 | 153 | {persistedPort 154 | ? isMutating 155 | ? 'Updating' 156 | : 'Update' 157 | : isMutating 158 | ? 'Starting' 159 | : 'Start'} 160 | 161 | 162 | )} 163 |
164 |
{persistedPort && port === persistedPort ? renderService() : ''}
165 | 166 | ); 167 | } 168 | 169 | function renderService(): JSX.Element { 170 | if (data?.Services[persistedPort]) { 171 | return ( 172 |
173 | Port {persistedPort} is currently started by "{data?.Services[persistedPort]}" 174 |
175 | ); 176 | } 177 | 178 | return ( 179 |
180 | It seems there's no service currently utilizing port {persistedPort}. Please ensure you 181 | start a local service that is bound to port {persistedPort}. 182 |
183 | ); 184 | } 185 | 186 | async function handleReset(e) { 187 | e.preventDefault(); 188 | e.stopPropagation(); 189 | 190 | setIsDeleting(true); 191 | 192 | const resp = (await fetchWithUser('/serve', { 193 | method: 'DELETE', 194 | body: '{}', 195 | })) as WithErrors; 196 | if (resp.Errors?.length && resp.Errors[0].Type === 'REQUIRES_SUDO') { 197 | vsCodeAPI.postMessage({ 198 | type: 'sudoPrompt', 199 | operation: 'delete', 200 | }); 201 | } 202 | 203 | setIsDeleting(false); 204 | setPreviousPort(port); 205 | setPort(''); 206 | 207 | // trigger refresh 208 | await mutate(); 209 | } 210 | 211 | async function handleSubmit(e: FormEvent) { 212 | e.preventDefault(); 213 | 214 | const form = new FormData(e.target as HTMLFormElement); 215 | const port = form.get('port'); 216 | 217 | if (!port) { 218 | return; 219 | } 220 | 221 | const params: ServeParams = { 222 | protocol: 'https', 223 | port: 443, 224 | mountPoint: '/', 225 | source: `http://127.0.0.1:${port}`, 226 | funnel: true, 227 | }; 228 | const resp = (await trigger(params)) as WithErrors; 229 | if (resp.Errors?.length && resp.Errors[0].Type === 'REQUIRES_SUDO') { 230 | vsCodeAPI.postMessage({ 231 | type: 'sudoPrompt', 232 | operation: 'add', 233 | params, 234 | }); 235 | } 236 | } 237 | }; 238 | -------------------------------------------------------------------------------- /src/webviews/serve-panel/window.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | tailscale: { 3 | url: string; 4 | authkey: string; 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['./src/webviews/serve-panel/**/*.{js,jsx,ts,tsx}'], 4 | theme: { 5 | colors: { 6 | // Global 7 | background: 'var(--vscode-editor-background, #1e1e1e)', 8 | foreground: 'var(--vscode-editor-foreground, #cccccc)', 9 | contrastActiveBorder: 'var(--vscode-contrastActiveBorder, #f38518)', 10 | contrastBorder: 'var(--vscode-contrastBorder, #6fc3df)', 11 | disabledOpacity: 0.4, 12 | errorForeground: 'var(--vscode-errorForeground)', 13 | focusBorder: 'var(--vscode-focusBorder, #007fd4)', 14 | 15 | // Notifications 16 | notificationsBackground: 'var(--vscode-notifications-background)', 17 | notificationsErrorIconForeground: 'var(--vscode-notificationsErrorIcon-foreground)', 18 | notificationsForeground: 'var(--vscode-notifications-foreground)', 19 | 20 | // Banner 21 | bannerBackground: 'var(--vscode-banner-background)', 22 | bannerForeground: 'var(--vscode-banner-foreground)', 23 | bannerIconForeground: 'var(--vscode-banner-iconForeground)', 24 | 25 | // Badge 26 | badgeBackground: 'var(--vscode-badge-background)', 27 | badgeForeground: 'var(--vscode-badge-foreground)', 28 | 29 | // Text Field & Area 30 | inputBackground: 'var(--vscode-input-background, #3c3c3c)', 31 | inputFocusOutline: 'var(--vscode-activityBar-activeBorder)', 32 | inputForeground: 'var(--vscode-input-foreground, #cccccc)', 33 | inputPlaceholderForeground: 'var(--vscode-input-placeholderForeground, #cccccc)', 34 | 35 | // Button 36 | buttonBackground: 'var(--vscode-button-background)', 37 | }, 38 | extend: {}, 39 | }, 40 | plugins: [], 41 | }; 42 | -------------------------------------------------------------------------------- /tool/go: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # This script acts like the "go" command, but uses Tailscale's 4 | # currently-desired version. If it's not installed, it will error 5 | # out and tell you to run ./tool/go from oss or corp to install it. 6 | 7 | set -eu 8 | 9 | if [ "${CI:-}" = "true" ]; then 10 | set -x 11 | fi 12 | 13 | GO="$HOME/.cache/tailscale-go/bin/go" 14 | 15 | if [ ! -e "$GO" ]; then 16 | echo "go tool is not installed. Run './tool/go' from https://github.com/tailscale/tailscale to install it." >&2 17 | exit 1 18 | fi 19 | 20 | unset GOROOT 21 | exec "$GO" "$@" 22 | -------------------------------------------------------------------------------- /tool/node: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Run a command with our local node install, rather than any globally installed 3 | # instance. 4 | 5 | set -euo pipefail 6 | 7 | if [[ "${CI:-}" == "true" ]]; then 8 | set -x 9 | fi 10 | 11 | ( 12 | if [[ "${CI:-}" == "true" ]]; then 13 | set -x 14 | fi 15 | 16 | repo_root="${BASH_SOURCE%/*}/../" 17 | cd "$repo_root" 18 | 19 | cachedir="$HOME/.cache/tailscale-node" 20 | tarball="${cachedir}.tar.gz" 21 | 22 | read -r want_rev &2 38 | exit 1 39 | fi 40 | ln -sf "$nix_node" "$cachedir" 41 | else 42 | # works for "linux" and "darwin" 43 | OS=$(uname -s | tr A-Z a-z) 44 | ARCH=$(uname -m) 45 | if [ "$ARCH" = "x86_64" ]; then 46 | ARCH="x64" 47 | fi 48 | if [ "$ARCH" = "aarch64" ]; then 49 | ARCH="arm64" 50 | fi 51 | mkdir -p "$cachedir" 52 | curl -f -L -o "$tarball" "https://nodejs.org/dist/v${want_rev}/node-v${want_rev}-${OS}-${ARCH}.tar.gz" 53 | (cd "$cachedir" && tar --strip-components=1 -xf "$tarball") 54 | rm -f "$tarball" 55 | fi 56 | fi 57 | ) 58 | 59 | export PATH="$HOME/.cache/tailscale-node/bin:$PATH" 60 | exec "$HOME/.cache/tailscale-node/bin/node" "$@" 61 | -------------------------------------------------------------------------------- /tool/yarn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Run a command with our local yarn install, rather than any globally installed 3 | # instance. 4 | 5 | set -euo pipefail 6 | 7 | if [[ "${CI:-}" == "true" ]]; then 8 | set -x 9 | fi 10 | 11 | ( 12 | if [[ "${CI:-}" == "true" ]]; then 13 | set -x 14 | fi 15 | 16 | repo_root="${BASH_SOURCE%/*}/../" 17 | cd "$repo_root" 18 | 19 | ./tool/node --version >/dev/null # Ensure node is unpacked and ready 20 | 21 | cachedir="$HOME/.cache/tailscale-yarn" 22 | tarball="${cachedir}.tar.gz" 23 | 24 | read -r want_rev Check it out at {{ .ReleaseURL }}' 105 | git: 106 | tag_sort: -version:refname 107 | github_urls: 108 | download: https://github.com 109 | gitlab_urls: 110 | download: https://gitlab.com 111 | -------------------------------------------------------------------------------- /tsrelay/dist/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_name": "vscode-tailscale", 3 | "tag": "v0.4.0", 4 | "previous_tag": "latest", 5 | "version": "0.4.0-SNAPSHOT-3e0e2c5", 6 | "commit": "3e0e2c5f543ee96844d0e2aec562825cc3753a80", 7 | "date": "2023-05-26T09:22:16.184978-07:00", 8 | "runtime": { "goos": "darwin", "goarch": "arm64" } 9 | } 10 | -------------------------------------------------------------------------------- /tsrelay/dist/vscode-tailscale_darwin_amd64_v1/vscode-tailscale: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale-dev/vscode-tailscale/490a435605f20f90905bac9a28fe5b9a82f48b78/tsrelay/dist/vscode-tailscale_darwin_amd64_v1/vscode-tailscale -------------------------------------------------------------------------------- /tsrelay/dist/vscode-tailscale_darwin_arm64/vscode-tailscale: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale-dev/vscode-tailscale/490a435605f20f90905bac9a28fe5b9a82f48b78/tsrelay/dist/vscode-tailscale_darwin_arm64/vscode-tailscale -------------------------------------------------------------------------------- /tsrelay/dist/vscode-tailscale_linux_386/vscode-tailscale: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale-dev/vscode-tailscale/490a435605f20f90905bac9a28fe5b9a82f48b78/tsrelay/dist/vscode-tailscale_linux_386/vscode-tailscale -------------------------------------------------------------------------------- /tsrelay/dist/vscode-tailscale_linux_amd64_v1/vscode-tailscale: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale-dev/vscode-tailscale/490a435605f20f90905bac9a28fe5b9a82f48b78/tsrelay/dist/vscode-tailscale_linux_amd64_v1/vscode-tailscale -------------------------------------------------------------------------------- /tsrelay/dist/vscode-tailscale_linux_arm64/vscode-tailscale: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale-dev/vscode-tailscale/490a435605f20f90905bac9a28fe5b9a82f48b78/tsrelay/dist/vscode-tailscale_linux_arm64/vscode-tailscale -------------------------------------------------------------------------------- /tsrelay/dist/vscode-tailscale_windows_386/vscode-tailscale.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale-dev/vscode-tailscale/490a435605f20f90905bac9a28fe5b9a82f48b78/tsrelay/dist/vscode-tailscale_windows_386/vscode-tailscale.exe -------------------------------------------------------------------------------- /tsrelay/dist/vscode-tailscale_windows_amd64_v1/vscode-tailscale.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale-dev/vscode-tailscale/490a435605f20f90905bac9a28fe5b9a82f48b78/tsrelay/dist/vscode-tailscale_windows_amd64_v1/vscode-tailscale.exe -------------------------------------------------------------------------------- /tsrelay/dist/vscode-tailscale_windows_arm64/vscode-tailscale.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale-dev/vscode-tailscale/490a435605f20f90905bac9a28fe5b9a82f48b78/tsrelay/dist/vscode-tailscale_windows_arm64/vscode-tailscale.exe -------------------------------------------------------------------------------- /tsrelay/handler/auth_middleware.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import "net/http" 4 | 5 | func (h *handler) authMiddleware(next http.Handler) http.Handler { 6 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 7 | user, _, ok := r.BasicAuth() 8 | 9 | // TODO: consider locking down to vscode-webviews://* URLs by checking 10 | // r.Header.Get("Origin") only in production builds. 11 | w.Header().Set("Access-Control-Allow-Origin", "*") 12 | w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE") 13 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") 14 | 15 | if r.Method == http.MethodOptions { 16 | // Handle preflight request 17 | w.WriteHeader(http.StatusNoContent) 18 | return 19 | } 20 | 21 | if !ok { 22 | w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`) 23 | } 24 | 25 | if user != h.nonce { 26 | // TODO: return JSON for all errors 27 | http.Error(w, "unauthorized", http.StatusUnauthorized) 28 | return 29 | } 30 | next.ServeHTTP(w, r) 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /tsrelay/handler/create_serve.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "strings" 11 | 12 | "tailscale.com/client/tailscale" 13 | "tailscale.com/ipn" 14 | ) 15 | 16 | type serveRequest struct { 17 | Protocol string 18 | Source string 19 | Port uint16 20 | MountPoint string 21 | Funnel bool 22 | } 23 | 24 | func (h *handler) createServeHandler(w http.ResponseWriter, r *http.Request) { 25 | if err := h.createServe(r.Context(), r.Body); err != nil { 26 | var re RelayError 27 | if errors.As(err, &re) { 28 | w.WriteHeader(re.statusCode) 29 | json.NewEncoder(w).Encode(re) 30 | return 31 | } 32 | h.l.Println("error creating serve:", err) 33 | http.Error(w, err.Error(), 500) 34 | return 35 | } 36 | w.Write([]byte(`{}`)) 37 | } 38 | 39 | // createServe is the programtic equivalent of "tailscale serve --set-raw" 40 | // it returns the config as json in case of an error. 41 | func (h *handler) createServe(ctx context.Context, body io.Reader) error { 42 | var req serveRequest 43 | err := json.NewDecoder(body).Decode(&req) 44 | if err != nil { 45 | return fmt.Errorf("error decoding request body: %w", err) 46 | } 47 | if req.Protocol != "https" { 48 | return fmt.Errorf("unsupported protocol: %q", req.Protocol) 49 | } 50 | sc, dns, err := h.serveConfigDNS(ctx) 51 | if err != nil { 52 | return fmt.Errorf("error getting config: %w", err) 53 | } 54 | hostPort := ipn.HostPort(fmt.Sprintf("%s:%d", dns, req.Port)) 55 | setHandler(sc, hostPort, req) 56 | if req.Funnel { 57 | if sc.AllowFunnel == nil { 58 | sc.AllowFunnel = make(map[ipn.HostPort]bool) 59 | } 60 | sc.AllowFunnel[hostPort] = true 61 | } else { 62 | delete(sc.AllowFunnel, hostPort) 63 | } 64 | err = h.setServeCfg(ctx, sc) 65 | if err != nil { 66 | if tailscale.IsAccessDeniedError(err) { 67 | cfgJSON, err := json.Marshal(sc) 68 | if err != nil { 69 | return fmt.Errorf("error marshaling own config: %w", err) 70 | } 71 | re := RelayError{ 72 | statusCode: http.StatusForbidden, 73 | Errors: []Error{{ 74 | Type: RequiresSudo, 75 | Command: fmt.Sprintf(`echo %s | sudo tailscale serve --set-raw`, cfgJSON), 76 | }}, 77 | } 78 | return re 79 | } 80 | if err != nil { 81 | return fmt.Errorf("error marshaling config: %w", err) 82 | } 83 | return fmt.Errorf("error setting serve config: %w", err) 84 | } 85 | return nil 86 | } 87 | 88 | func (h *handler) serveConfigDNS(ctx context.Context) (*ipn.ServeConfig, string, error) { 89 | st, sc, err := h.getConfigs(ctx) 90 | if err != nil { 91 | return nil, "", fmt.Errorf("error getting configs: %w", err) 92 | } 93 | if sc == nil { 94 | sc = &ipn.ServeConfig{} 95 | } 96 | dns := strings.TrimSuffix(st.Self.DNSName, ".") 97 | return sc, dns, nil 98 | } 99 | 100 | func setHandler(sc *ipn.ServeConfig, newHP ipn.HostPort, req serveRequest) { 101 | if sc.TCP == nil { 102 | sc.TCP = make(map[uint16]*ipn.TCPPortHandler) 103 | } 104 | if _, ok := sc.TCP[req.Port]; !ok { 105 | sc.TCP[req.Port] = &ipn.TCPPortHandler{ 106 | HTTPS: true, 107 | } 108 | } 109 | if sc.Web == nil { 110 | sc.Web = make(map[ipn.HostPort]*ipn.WebServerConfig) 111 | } 112 | wsc, ok := sc.Web[newHP] 113 | if !ok { 114 | wsc = &ipn.WebServerConfig{} 115 | sc.Web[newHP] = wsc 116 | } 117 | if wsc.Handlers == nil { 118 | wsc.Handlers = make(map[string]*ipn.HTTPHandler) 119 | } 120 | wsc.Handlers[req.MountPoint] = &ipn.HTTPHandler{ 121 | Proxy: req.Source, 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /tsrelay/handler/delete_serve.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | 11 | "tailscale.com/ipn" 12 | ) 13 | 14 | func (h *handler) deleteServeHandler(w http.ResponseWriter, r *http.Request) { 15 | if err := h.deleteServe(r.Context(), r.Body); err != nil { 16 | var re RelayError 17 | if errors.As(err, &re) { 18 | w.WriteHeader(re.statusCode) 19 | json.NewEncoder(w).Encode(re) 20 | return 21 | } 22 | h.l.Println("error deleting serve:", err) 23 | http.Error(w, err.Error(), 500) 24 | return 25 | } 26 | w.Write([]byte(`{}`)) 27 | } 28 | 29 | func (h *handler) deleteServe(ctx context.Context, body io.Reader) error { 30 | var req serveRequest 31 | if body != nil && body != http.NoBody { 32 | err := json.NewDecoder(body).Decode(&req) 33 | if err != nil { 34 | return fmt.Errorf("error decoding request body: %w", err) 35 | } 36 | } 37 | 38 | // reset serve config if no request body 39 | if (req == serveRequest{}) { 40 | sc := &ipn.ServeConfig{} 41 | err := h.setServeCfg(ctx, sc) 42 | if err != nil { 43 | return fmt.Errorf("error setting serve config: %w", err) 44 | } 45 | return nil 46 | } 47 | 48 | if req.Protocol != "https" { 49 | return fmt.Errorf("unsupported protocol: %q", req.Protocol) 50 | } 51 | sc, dns, err := h.serveConfigDNS(ctx) 52 | if err != nil { 53 | return fmt.Errorf("error getting config: %w", err) 54 | } 55 | hostPort := ipn.HostPort(fmt.Sprintf("%s:%d", dns, req.Port)) 56 | deleteFromConfig(sc, hostPort, req) 57 | delete(sc.AllowFunnel, hostPort) 58 | if len(sc.AllowFunnel) == 0 { 59 | sc.AllowFunnel = nil 60 | } 61 | err = h.setServeCfg(ctx, sc) 62 | if err != nil { 63 | return fmt.Errorf("error setting serve config: %w", err) 64 | } 65 | return nil 66 | } 67 | 68 | func deleteFromConfig(sc *ipn.ServeConfig, newHP ipn.HostPort, req serveRequest) { 69 | delete(sc.AllowFunnel, newHP) 70 | if sc.TCP != nil { 71 | delete(sc.TCP, req.Port) 72 | } 73 | if sc.Web == nil { 74 | return 75 | } 76 | if sc.Web[newHP] == nil { 77 | return 78 | } 79 | wsc, ok := sc.Web[newHP] 80 | if !ok { 81 | return 82 | } 83 | if wsc.Handlers == nil { 84 | return 85 | } 86 | _, ok = wsc.Handlers[req.MountPoint] 87 | if !ok { 88 | return 89 | } 90 | delete(wsc.Handlers, req.MountPoint) 91 | if len(wsc.Handlers) == 0 { 92 | delete(sc.Web, newHP) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tsrelay/handler/error.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | // ErrorTypes for signaling 4 | // invalid states to the VSCode 5 | // extension. 6 | const ( 7 | // FunnelOff means the user does not have 8 | // funnel in their ACLs. 9 | FunnelOff = "FUNNEL_OFF" 10 | // HTTPSOff means the user has not enabled 11 | // https in the DNS section of the UI 12 | HTTPSOff = "HTTPS_OFF" 13 | // Offline can mean a user is not logged in 14 | // or is logged in but their key has expired. 15 | Offline = "OFFLINE" 16 | // RequiresSudo for when LocalBackend is run 17 | // with sudo but tsrelay is not 18 | RequiresSudo = "REQUIRES_SUDO" 19 | // NotRunning indicates tailscaled is 20 | // not running 21 | NotRunning = "NOT_RUNNING" 22 | // FlatpakRequiresRestart indicates that the flatpak 23 | // container needs to be fully restarted 24 | FlatpakRequiresRestart = "FLATPAK_REQUIRES_RESTART" 25 | ) 26 | 27 | // RelayError is a wrapper for Error 28 | type RelayError struct { 29 | statusCode int 30 | Errors []Error 31 | } 32 | 33 | // Error implements error. It returns a 34 | // static string as it is only needed to be 35 | // used for programatic type assertion. 36 | func (RelayError) Error() string { 37 | return "relay error" 38 | } 39 | 40 | // Error is a programmable error returned 41 | // to the typescript client 42 | type Error struct { 43 | Type string `json:",omitempty"` 44 | Command string `json:",omitempty"` 45 | } 46 | -------------------------------------------------------------------------------- /tsrelay/handler/get_configs.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "golang.org/x/sync/errgroup" 8 | "tailscale.com/ipn" 9 | "tailscale.com/ipn/ipnstate" 10 | ) 11 | 12 | func (h *handler) getConfigs(ctx context.Context) (*ipnstate.Status, *ipn.ServeConfig, error) { 13 | var ( 14 | st *ipnstate.Status 15 | sc *ipn.ServeConfig 16 | ) 17 | g, ctx := errgroup.WithContext(ctx) 18 | g.Go(func() error { 19 | var err error 20 | sc, err = h.lc.GetServeConfig(ctx) 21 | if err != nil { 22 | return fmt.Errorf("error getting serve config: %w", err) 23 | } 24 | return nil 25 | }) 26 | g.Go(func() error { 27 | var err error 28 | st, err = h.lc.StatusWithoutPeers(ctx) 29 | if err != nil { 30 | return fmt.Errorf("error getting status: %w", err) 31 | } 32 | return nil 33 | }) 34 | 35 | return st, sc, g.Wait() 36 | } 37 | -------------------------------------------------------------------------------- /tsrelay/handler/get_peers.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "io" 8 | "net" 9 | "net/http" 10 | "sort" 11 | "strings" 12 | ) 13 | 14 | // getPeersResponse is a subset of ipnstate.Status 15 | // which contains only what the plugin needs 16 | // to reduce serialization size in addition 17 | // to some helper fields for the typescript frontend 18 | type getPeersResponse struct { 19 | PeerGroups []*peerGroup 20 | CurrentTailnet *currentTailnet 21 | Errors []Error `json:",omitempty"` 22 | } 23 | 24 | type currentTailnet struct { 25 | Name string 26 | MagicDNSSuffix string 27 | MagicDNSEnabled bool 28 | } 29 | 30 | type peerGroup struct { 31 | Name string 32 | Peers []*peerStatus 33 | } 34 | 35 | func (h *handler) getPeersHandler(w http.ResponseWriter, r *http.Request) { 36 | s, err := h.getPeers(r.Context(), r.Body) 37 | if err != nil { 38 | var re RelayError 39 | if errors.As(err, &re) { 40 | w.WriteHeader(re.statusCode) 41 | json.NewEncoder(w).Encode(re) 42 | return 43 | } 44 | h.l.Println("error creating serve:", err) 45 | http.Error(w, err.Error(), 500) 46 | return 47 | } 48 | 49 | json.NewEncoder(w).Encode(s) 50 | } 51 | 52 | func (h *handler) getPeers(ctx context.Context, body io.Reader) (*getPeersResponse, error) { 53 | if h.requiresRestart { 54 | return nil, RelayError{ 55 | statusCode: http.StatusPreconditionFailed, 56 | Errors: []Error{{Type: FlatpakRequiresRestart}}, 57 | } 58 | } 59 | 60 | st, err := h.lc.Status(ctx) 61 | if err != nil { 62 | var oe *net.OpError 63 | if errors.As(err, &oe) && oe.Op == "dial" { 64 | return nil, RelayError{ 65 | statusCode: http.StatusServiceUnavailable, 66 | Errors: []Error{{Type: NotRunning}}, 67 | } 68 | } 69 | return nil, err 70 | } 71 | 72 | s := getPeersResponse{PeerGroups: []*peerGroup{}} 73 | peerGroups := [...]*peerGroup{ 74 | {Name: "Managed by you"}, 75 | {Name: "All machines"}, 76 | {Name: "Offline machines"}, 77 | } 78 | 79 | if st.BackendState == "NeedsLogin" || (st.Self != nil && !st.Self.Online) { 80 | s.Errors = append(s.Errors, Error{ 81 | Type: Offline, 82 | }) 83 | } 84 | 85 | // CurrentTailnet can be offline when you are logged out 86 | if st.CurrentTailnet != nil { 87 | s.CurrentTailnet = ¤tTailnet{ 88 | Name: st.CurrentTailnet.Name, 89 | MagicDNSSuffix: st.CurrentTailnet.MagicDNSSuffix, 90 | MagicDNSEnabled: st.CurrentTailnet.MagicDNSEnabled, 91 | } 92 | } 93 | 94 | for _, p := range st.Peer { 95 | // ShareeNode indicates this node exists in the netmap because 96 | // it's owned by a shared-to user and that node might connect 97 | // to us. These nodes are hidden by "tailscale status", but present 98 | // in JSON output so we should filter out. 99 | if p.ShareeNode { 100 | continue 101 | } 102 | 103 | serverName := p.HostName 104 | if p.DNSName != "" { 105 | parts := strings.SplitN(p.DNSName, ".", 2) 106 | if len(parts) > 0 { 107 | serverName = parts[0] 108 | } 109 | } 110 | 111 | // removes the root label/trailing period from the DNSName 112 | // before: "amalie.foo.ts.net.", after: "amalie.foo.ts.net" 113 | dnsNameNoRootLabel := strings.TrimSuffix(p.DNSName, ".") 114 | 115 | // if the DNSName does not end with the magic DNS suffix, it is an external peer 116 | isExternal := !strings.HasSuffix(dnsNameNoRootLabel, st.CurrentTailnet.MagicDNSSuffix) 117 | 118 | addr := dnsNameNoRootLabel 119 | if addr == "" && len(p.TailscaleIPs) > 0 { 120 | addr = p.TailscaleIPs[0].String() 121 | } 122 | peer := &peerStatus{ 123 | DNSName: dnsNameNoRootLabel, 124 | ServerName: serverName, 125 | Online: p.Online, 126 | ID: p.ID, 127 | HostName: p.HostName, 128 | TailscaleIPs: p.TailscaleIPs, 129 | IsExternal: isExternal, 130 | SSHEnabled: len(p.SSH_HostKeys) > 0, 131 | Address: addr, 132 | } 133 | 134 | if !p.Online { 135 | peerGroups[2].Peers = append(peerGroups[2].Peers, peer) 136 | } else if p.UserID == st.Self.UserID { 137 | peerGroups[0].Peers = append(peerGroups[0].Peers, peer) 138 | } else { 139 | peerGroups[1].Peers = append(peerGroups[1].Peers, peer) 140 | } 141 | } 142 | 143 | for _, pg := range peerGroups { 144 | if len(pg.Peers) > 0 { 145 | s.PeerGroups = append(s.PeerGroups, pg) 146 | } 147 | } 148 | 149 | for _, pg := range s.PeerGroups { 150 | peers := pg.Peers 151 | sort.Slice(peers, func(i, j int) bool { 152 | return peers[i].ServerName < peers[j].ServerName 153 | }) 154 | } 155 | 156 | return &s, nil 157 | } 158 | -------------------------------------------------------------------------------- /tsrelay/handler/get_serve.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "io" 8 | "net" 9 | "net/http" 10 | "net/netip" 11 | "net/url" 12 | "strconv" 13 | "strings" 14 | "sync" 15 | 16 | "golang.org/x/exp/slices" 17 | "tailscale.com/ipn" 18 | "tailscale.com/portlist" 19 | "tailscale.com/tailcfg" 20 | ) 21 | 22 | // serveStatus is a subset of ipnstate.Status 23 | // which contains only what the plugin needs 24 | // to reduce serialization size in addition 25 | // to some helper fields for the typescript frontend 26 | type serveStatus struct { 27 | ServeConfig *ipn.ServeConfig 28 | Services map[uint16]string 29 | BackendState string 30 | Self *peerStatus 31 | FunnelPorts []int 32 | Errors []Error `json:",omitempty"` 33 | } 34 | 35 | type peerStatus struct { 36 | DNSName string 37 | Online bool 38 | 39 | // For node explorer 40 | ID tailcfg.StableNodeID 41 | ServerName string 42 | HostName string 43 | TailscaleIPs []netip.Addr 44 | IsExternal bool 45 | SSHEnabled bool 46 | 47 | // The address you can use to connect/ssh. Either DNSName or IPv4. 48 | // You can connect in various ways but some are not stable. For example 49 | // HostName works unless you change your machine's name. 50 | Address string 51 | } 52 | 53 | func (h *handler) getServeHandler(w http.ResponseWriter, r *http.Request) { 54 | s, err := h.getServe(r.Context(), r.Body) 55 | if err != nil { 56 | var re RelayError 57 | if errors.As(err, &re) { 58 | w.WriteHeader(re.statusCode) 59 | json.NewEncoder(w).Encode(re) 60 | return 61 | } 62 | h.l.Println("error creating serve:", err) 63 | http.Error(w, err.Error(), 500) 64 | return 65 | } 66 | 67 | json.NewEncoder(w).Encode(s) 68 | } 69 | 70 | func (h *handler) getServe(ctx context.Context, body io.Reader) (*serveStatus, error) { 71 | if h.requiresRestart { 72 | return nil, RelayError{ 73 | statusCode: http.StatusPreconditionFailed, 74 | Errors: []Error{{Type: FlatpakRequiresRestart}}, 75 | } 76 | } 77 | var wg sync.WaitGroup 78 | wg.Add(1) 79 | portMap := map[uint16]string{} 80 | go func() { 81 | defer wg.Done() 82 | p := &portlist.Poller{IncludeLocalhost: true} 83 | defer p.Close() 84 | ports, _, err := p.Poll() 85 | if err != nil { 86 | h.l.Printf("error polling for serve: %v", err) 87 | return 88 | } 89 | for _, p := range ports { 90 | portMap[p.Port] = p.Process 91 | } 92 | }() 93 | 94 | st, sc, err := h.getConfigs(ctx) 95 | if err != nil { 96 | var oe *net.OpError 97 | if errors.As(err, &oe) && oe.Op == "dial" { 98 | return nil, RelayError{ 99 | statusCode: http.StatusServiceUnavailable, 100 | Errors: []Error{{Type: NotRunning}}, 101 | } 102 | } 103 | return nil, err 104 | } 105 | 106 | s := serveStatus{ 107 | ServeConfig: sc, 108 | Services: make(map[uint16]string), 109 | BackendState: st.BackendState, 110 | FunnelPorts: []int{}, 111 | } 112 | 113 | wg.Wait() 114 | if sc != nil { 115 | for _, webCfg := range sc.Web { 116 | for _, addr := range webCfg.Handlers { 117 | if addr.Proxy == "" { 118 | continue 119 | } 120 | u, err := url.Parse(addr.Proxy) 121 | if err != nil { 122 | h.l.Printf("error parsing address proxy %q: %v", addr.Proxy, err) 123 | continue 124 | } 125 | portInt, err := strconv.Atoi(u.Port()) 126 | if err != nil { 127 | h.l.Printf("error parsing port %q of proxy %q: %v", u.Port(), addr.Proxy, err) 128 | continue 129 | } 130 | port := uint16(portInt) 131 | if process, ok := portMap[port]; ok { 132 | s.Services[port] = process 133 | } 134 | } 135 | } 136 | } 137 | 138 | if st.Self != nil { 139 | s.Self = &peerStatus{ 140 | DNSName: st.Self.DNSName, 141 | Online: st.Self.Online, 142 | ID: st.Self.ID, 143 | HostName: st.Self.HostName, 144 | TailscaleIPs: st.Self.TailscaleIPs, 145 | } 146 | 147 | if st.Self.HasCap(tailcfg.CapabilityWarnFunnelNoInvite) || 148 | !st.Self.HasCap(tailcfg.NodeAttrFunnel) { 149 | s.Errors = append(s.Errors, Error{ 150 | Type: FunnelOff, 151 | }) 152 | } 153 | if st.Self.HasCap(tailcfg.CapabilityWarnFunnelNoHTTPS) { 154 | s.Errors = append(s.Errors, Error{ 155 | Type: HTTPSOff, 156 | }) 157 | } 158 | if !st.Self.Online || s.BackendState == "NeedsLogin" { 159 | s.Errors = append(s.Errors, Error{ 160 | Type: Offline, 161 | }) 162 | } 163 | } 164 | 165 | var u *url.URL 166 | 167 | idx := slices.IndexFunc(st.Self.Capabilities, func(s tailcfg.NodeCapability) bool { 168 | return strings.HasPrefix(string(s), string(tailcfg.CapabilityFunnelPorts)) 169 | }) 170 | 171 | if idx >= 0 { 172 | u, err = url.Parse(string(st.Self.Capabilities[idx])) 173 | if err != nil { 174 | return nil, err 175 | } 176 | } else if st.Self.CapMap != nil { 177 | for c := range st.Self.CapMap { 178 | if strings.HasPrefix(string(c), string(tailcfg.CapabilityFunnelPorts)) { 179 | u, err = url.Parse(string(c)) 180 | if err != nil { 181 | return nil, err 182 | } 183 | break 184 | } 185 | } 186 | } 187 | 188 | if u != nil { 189 | ports := strings.Split(strings.TrimSpace(u.Query().Get("ports")), ",") 190 | 191 | for _, ps := range ports { 192 | p, err := strconv.Atoi(ps) 193 | if err != nil { 194 | return nil, err 195 | } 196 | 197 | s.FunnelPorts = append(s.FunnelPorts, p) 198 | } 199 | } 200 | 201 | return &s, nil 202 | } 203 | -------------------------------------------------------------------------------- /tsrelay/handler/handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/http" 5 | "sync" 6 | 7 | "github.com/go-chi/chi/v5" 8 | "github.com/gorilla/websocket" 9 | "github.com/tailscale-dev/vscode-tailscale/tsrelay/logger" 10 | "tailscale.com/portlist" 11 | ) 12 | 13 | // NewHandler returns a new http handler for interactions between 14 | // the typescript extension and the Go tsrelay server. 15 | func NewHandler(lc LocalClient, nonce string, l logger.Logger, requiresRestart bool) http.Handler { 16 | return newHandler(&handler{ 17 | nonce: nonce, 18 | lc: lc, 19 | l: l, 20 | pids: make(map[int]struct{}), 21 | prev: make(map[uint16]portlist.Port), 22 | onPortUpdate: func() {}, 23 | requiresRestart: requiresRestart, 24 | }) 25 | } 26 | 27 | type handler struct { 28 | sync.Mutex 29 | nonce string 30 | lc LocalClient 31 | l logger.Logger 32 | u websocket.Upgrader 33 | pids map[int]struct{} 34 | prev map[uint16]portlist.Port 35 | onPortUpdate func() // callback for async testing 36 | requiresRestart bool 37 | } 38 | 39 | func newHandler(h *handler) http.Handler { 40 | r := chi.NewRouter() 41 | r.Use(h.authMiddleware) 42 | r.Get("/peers", h.getPeersHandler) 43 | r.Get("/serve", h.getServeHandler) 44 | r.Post("/serve", h.createServeHandler) 45 | r.Delete("/serve", h.deleteServeHandler) 46 | r.Post("/funnel", h.setFunnelHandler) 47 | r.Get("/portdisco", h.portDiscoHandler) 48 | return r 49 | } 50 | -------------------------------------------------------------------------------- /tsrelay/handler/local_client.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net" 7 | "os" 8 | "sync" 9 | 10 | "tailscale.com/client/tailscale" 11 | "tailscale.com/ipn" 12 | "tailscale.com/ipn/ipnstate" 13 | ) 14 | 15 | // static check for local client interface implementation 16 | var _ LocalClient = (*tailscale.LocalClient)(nil) 17 | 18 | // LocalClient is an abstraction of tailscale.LocalClient 19 | type LocalClient interface { 20 | Status(ctx context.Context) (*ipnstate.Status, error) 21 | GetServeConfig(ctx context.Context) (*ipn.ServeConfig, error) 22 | StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) 23 | SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error 24 | } 25 | 26 | type profile struct { 27 | Status *ipnstate.Status 28 | ServeConfig *ipn.ServeConfig 29 | MockOffline bool 30 | MockAccessDenied bool 31 | } 32 | 33 | // NewMockClient returns a mock localClient 34 | // based on the given json file. The format of the file 35 | // is described in the profile struct. Note that SET 36 | // operations update the given input in memory. 37 | func NewMockClient(file string) (LocalClient, error) { 38 | bts, err := os.ReadFile(file) 39 | if err != nil { 40 | return nil, err 41 | } 42 | var p profile 43 | return &mockClient{p: &p}, json.Unmarshal(bts, &p) 44 | } 45 | 46 | type mockClient struct { 47 | sync.Mutex 48 | p *profile 49 | } 50 | 51 | // GetServeConfig implements localClient. 52 | func (m *mockClient) GetServeConfig(ctx context.Context) (*ipn.ServeConfig, error) { 53 | if m.p.MockOffline { 54 | return nil, &net.OpError{Op: "dial"} 55 | } 56 | return m.p.ServeConfig, nil 57 | } 58 | 59 | // SetServeConfig implements localClient. 60 | func (m *mockClient) SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error { 61 | if m.p.MockAccessDenied { 62 | return &tailscale.AccessDeniedError{} 63 | } 64 | m.Lock() 65 | defer m.Unlock() 66 | m.p.ServeConfig = config 67 | return nil 68 | } 69 | 70 | // Status implements localClient. 71 | func (m *mockClient) Status(ctx context.Context) (*ipnstate.Status, error) { 72 | if m.p.MockOffline || m.p.Status == nil { 73 | return nil, &net.OpError{Op: "dial"} 74 | } 75 | return m.p.Status, nil 76 | } 77 | 78 | // StatusWithoutPeers implements localClient. 79 | func (m *mockClient) StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) { 80 | if m.p.MockOffline || m.p.Status == nil { 81 | return nil, &net.OpError{Op: "dial"} 82 | } 83 | copy := *(m.p.Status) 84 | copy.Peer = nil 85 | return ©, nil 86 | } 87 | -------------------------------------------------------------------------------- /tsrelay/handler/portdisco.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/gorilla/websocket" 10 | "github.com/mitchellh/go-ps" 11 | "tailscale.com/portlist" 12 | ) 13 | 14 | func (h *handler) portDiscoHandler(w http.ResponseWriter, r *http.Request) { 15 | c, err := h.u.Upgrade(w, r, nil) 16 | if err != nil { 17 | h.l.Printf("error upgrading to websocket: %v", err) 18 | return 19 | } 20 | err = h.runPortDisco(r.Context(), c) 21 | if err != nil { 22 | h.l.Printf("error running port discovery: %v", err) 23 | return 24 | } 25 | } 26 | 27 | type wsMessage struct { 28 | Type string `json:"type"` 29 | PID int `json:"pid"` 30 | Port int `json:"port"` 31 | Message string `json:"message"` 32 | } 33 | 34 | func (h *handler) runPortDisco(ctx context.Context, c *websocket.Conn) error { 35 | defer c.Close() 36 | closeCh := make(chan struct{}) 37 | go func() { 38 | defer close(closeCh) 39 | for { 40 | if ctx.Err() != nil { 41 | return 42 | } 43 | var msg wsMessage 44 | err := c.ReadJSON(&msg) 45 | if err != nil { 46 | // TOOD: handle connection closed 47 | if !websocket.IsUnexpectedCloseError(err) { 48 | h.l.VPrintf("error reading json: %v", err) 49 | } 50 | return 51 | } 52 | h.Lock() 53 | switch msg.Type { 54 | case "addPID": 55 | h.l.VPrintln("adding pid", msg.PID) 56 | h.pids[msg.PID] = struct{}{} 57 | h.onPortUpdate() 58 | case "removePID": 59 | h.l.VPrintln("removing pid", msg.PID) 60 | delete(h.pids, msg.PID) 61 | h.onPortUpdate() 62 | default: 63 | h.l.Printf("unrecognized websocket message: %q", msg.Type) 64 | } 65 | h.Unlock() 66 | } 67 | }() 68 | 69 | p := &portlist.Poller{ 70 | IncludeLocalhost: true, 71 | } 72 | ticker := time.NewTicker(3 * time.Second) 73 | defer ticker.Stop() 74 | 75 | // eagerly load already open ports to avoid spam notifications 76 | ports, _, err := p.Poll() 77 | if err != nil { 78 | return fmt.Errorf("error running initial poll: %w", err) 79 | } 80 | for _, p := range ports { 81 | if p.Proto != "tcp" { 82 | continue 83 | } 84 | h.l.VPrintln("pre-setting", p.Port, p.Pid, p.Process) 85 | h.prev[p.Port] = p 86 | } 87 | h.l.Println("initial ports are set") 88 | h.onPortUpdate() 89 | 90 | for { 91 | select { 92 | case <-ctx.Done(): 93 | return ctx.Err() 94 | case <-closeCh: 95 | h.l.Println("portdisco reader is closed") 96 | return nil 97 | case <-ticker.C: 98 | ports, changed, err := p.Poll() 99 | if err != nil { 100 | h.l.Printf("error receiving portlist update: %v", err) 101 | continue 102 | } 103 | if !changed { 104 | continue 105 | } 106 | err = h.handlePortUpdates(c, ports) 107 | if err != nil { 108 | return fmt.Errorf("error handling port updates: %w", err) 109 | } 110 | } 111 | } 112 | } 113 | 114 | func (h *handler) handlePortUpdates(c *websocket.Conn, up []portlist.Port) error { 115 | h.l.VPrintln("ports were updated") 116 | h.Lock() 117 | h.l.VPrintln("up is", len(up)) 118 | for _, p := range up { 119 | if p.Proto != "tcp" { 120 | h.l.VPrintln("skipping", p.Port, "of", p.Proto) 121 | continue 122 | } 123 | if _, ok := h.prev[p.Port]; ok { 124 | h.l.VPrintln("skipping", p.Port, "because it already exists") 125 | continue 126 | } 127 | ok, err := h.matchesPID(p.Pid) 128 | if err != nil { 129 | h.l.Printf("error matching pid: %v", err) 130 | continue 131 | } 132 | if !ok { 133 | h.l.VPrintf("skipping unrelated port %d / %d", p.Port, p.Pid) 134 | continue 135 | } 136 | h.l.VPrintf("port %d matches pid %d", p.Port, p.Pid) 137 | h.prev[p.Port] = p 138 | err = c.WriteJSON(&wsMessage{ 139 | Type: "newPort", 140 | Port: int(p.Port), 141 | Message: fmt.Sprintf("Port %d was started by %q, would you like to share it over the internet with Tailscale Funnel?", p.Port, p.Process), 142 | }) 143 | if err != nil { 144 | h.Unlock() 145 | return fmt.Errorf("error notifying client: %w", err) 146 | } 147 | } 148 | h.Unlock() 149 | return nil 150 | } 151 | 152 | func (h *handler) matchesPID(pid int) (bool, error) { 153 | if _, ok := h.pids[pid]; ok { 154 | return true, nil 155 | } 156 | proc, err := ps.FindProcess(pid) 157 | if err != nil { 158 | return false, fmt.Errorf("error finding process: %w", err) 159 | } else if proc == nil { 160 | h.l.VPrintf("proc %d could not be found", pid) 161 | return false, nil 162 | } else if proc.PPid() == 0 { 163 | h.l.VPrintf("proc %d has no parent", pid) 164 | return false, nil 165 | } 166 | return h.matchesPID(proc.PPid()) 167 | } 168 | -------------------------------------------------------------------------------- /tsrelay/handler/portdisco_test.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "encoding/base64" 5 | "net" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "strings" 10 | "testing" 11 | "time" 12 | 13 | "github.com/gorilla/websocket" 14 | "github.com/tailscale-dev/vscode-tailscale/tsrelay/logger" 15 | "tailscale.com/portlist" 16 | ) 17 | 18 | func TestServe(t *testing.T) { 19 | portCh := make(chan struct{}, 2) 20 | h := &handler{ 21 | nonce: "123", 22 | l: logger.Nop, 23 | pids: make(map[int]struct{}), 24 | prev: make(map[uint16]portlist.Port), 25 | onPortUpdate: func() { portCh <- struct{}{} }, 26 | } 27 | srv := httptest.NewServer(newHandler(h)) 28 | t.Cleanup(srv.Close) 29 | 30 | headers := http.Header{} 31 | headers.Set("Authorization", "Basic "+basicAuth("123", "")) 32 | wsUR := strings.Replace(srv.URL, "http://", "ws://", 1) 33 | conn, _, err := websocket.DefaultDialer.Dial(wsUR+"/portdisco", headers) 34 | if err != nil { 35 | t.Fatal(err) 36 | } 37 | t.Cleanup(func() { conn.Close() }) 38 | err = conn.WriteJSON(&wsMessage{ 39 | Type: "addPID", 40 | PID: os.Getpid(), 41 | }) 42 | if err != nil { 43 | t.Fatal(err) 44 | } 45 | 46 | <-portCh 47 | <-portCh 48 | 49 | lst, err := net.Listen("tcp", "127.0.0.1:4593") 50 | if err != nil { 51 | t.Fatal(err) 52 | } 53 | t.Cleanup(func() { lst.Close() }) 54 | wantPort := lst.Addr().(*net.TCPAddr).Port 55 | 56 | err = conn.SetReadDeadline(time.Now().Add(time.Second * 15)) 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | var msg wsMessage 61 | err = conn.ReadJSON(&msg) 62 | if err != nil { 63 | t.Fatal(err) 64 | } 65 | if msg.Type != "newPort" { 66 | t.Fatalf("expected newPort type but got %q", msg.Type) 67 | } 68 | if msg.Port != wantPort { 69 | t.Fatalf("expected port to be %q but got %q", wantPort, msg.Port) 70 | } 71 | } 72 | 73 | func basicAuth(username, password string) string { 74 | auth := username + ":" + password 75 | return base64.StdEncoding.EncodeToString([]byte(auth)) 76 | } 77 | -------------------------------------------------------------------------------- /tsrelay/handler/set_funnel.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | 11 | "tailscale.com/ipn" 12 | ) 13 | 14 | func (h *handler) setFunnelHandler(w http.ResponseWriter, r *http.Request) { 15 | if r.Method != http.MethodPost { 16 | http.NotFound(w, r) 17 | return 18 | } 19 | err := h.setFunnel(r.Context(), r.Body) 20 | if err != nil { 21 | var re RelayError 22 | if errors.As(err, &re) { 23 | w.WriteHeader(re.statusCode) 24 | json.NewEncoder(w).Encode(re) 25 | return 26 | } 27 | h.l.Println("error toggling funnel:", err) 28 | http.Error(w, err.Error(), 500) 29 | return 30 | } 31 | w.Write([]byte(`{}`)) 32 | } 33 | 34 | type setFunnelRequest struct { 35 | On bool `json:"on"` 36 | Port int `json:"port"` 37 | } 38 | 39 | func (h *handler) setFunnel(ctx context.Context, body io.Reader) error { 40 | var req setFunnelRequest 41 | err := json.NewDecoder(body).Decode(&req) 42 | if err != nil { 43 | return fmt.Errorf("error decoding body: %w", err) 44 | } 45 | sc, dns, err := h.serveConfigDNS(ctx) 46 | if err != nil { 47 | return fmt.Errorf("error getting serve config: %w", err) 48 | } 49 | hp := ipn.HostPort(fmt.Sprintf("%s:%d", dns, req.Port)) 50 | if req.On { 51 | if sc.AllowFunnel == nil { 52 | sc.AllowFunnel = make(map[ipn.HostPort]bool) 53 | } 54 | sc.AllowFunnel[hp] = true 55 | } else { 56 | delete(sc.AllowFunnel, hp) 57 | if len(sc.AllowFunnel) == 0 { 58 | sc.AllowFunnel = nil 59 | } 60 | } 61 | err = h.setServeCfg(ctx, sc) 62 | if err != nil { 63 | return fmt.Errorf("error setting serve config: %w", err) 64 | } 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /tsrelay/handler/set_serve_config.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | 9 | "tailscale.com/client/tailscale" 10 | "tailscale.com/ipn" 11 | ) 12 | 13 | func (h *handler) setServeCfg(ctx context.Context, sc *ipn.ServeConfig) error { 14 | err := h.lc.SetServeConfig(ctx, sc) 15 | if err != nil { 16 | if tailscale.IsAccessDeniedError(err) { 17 | cfgJSON, err := json.Marshal(sc) 18 | if err != nil { 19 | return fmt.Errorf("error marshaling own config: %w", err) 20 | } 21 | re := RelayError{ 22 | statusCode: http.StatusForbidden, 23 | Errors: []Error{{ 24 | Type: RequiresSudo, 25 | Command: fmt.Sprintf(`echo %s | sudo tailscale serve --set-raw`, cfgJSON), 26 | }}, 27 | } 28 | return re 29 | } 30 | if err != nil { 31 | return fmt.Errorf("error marshaling config: %w", err) 32 | } 33 | return fmt.Errorf("error setting serve config: %w", err) 34 | } 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /tsrelay/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "io" 5 | "log" 6 | ) 7 | 8 | // Logger interface for handler routes 9 | type Logger interface { 10 | Println(v ...any) 11 | Printf(format string, v ...any) 12 | VPrintf(format string, v ...any) 13 | VPrintln(v ...any) 14 | } 15 | 16 | // New returns a new logger that logs to the given out. 17 | func New(out io.Writer, verbose bool) Logger { 18 | return &logger{ 19 | verbose: verbose, 20 | Logger: log.New(out, "", 0), 21 | } 22 | } 23 | 24 | type logger struct { 25 | verbose bool 26 | *log.Logger 27 | } 28 | 29 | func (l *logger) VPrintf(format string, v ...any) { 30 | if l.verbose { 31 | l.Printf(format, v...) 32 | } 33 | } 34 | 35 | func (l *logger) VPrintln(v ...any) { 36 | if l.verbose { 37 | l.Println(v...) 38 | } 39 | } 40 | 41 | // Nop is a logger that doesn't print to anywhere 42 | var Nop Logger = nopLogger{} 43 | 44 | type nopLogger struct { 45 | } 46 | 47 | func (nopLogger) Println(v ...any) {} 48 | func (nopLogger) Printf(format string, v ...any) {} 49 | func (nopLogger) VPrintf(format string, v ...any) {} 50 | func (nopLogger) VPrintln(v ...any) {} 51 | -------------------------------------------------------------------------------- /tsrelay/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "flag" 8 | "fmt" 9 | "io" 10 | "math/rand" 11 | "net" 12 | "net/http" 13 | "net/url" 14 | "os" 15 | "os/exec" 16 | "os/signal" 17 | "strings" 18 | "time" 19 | 20 | "github.com/tailscale-dev/vscode-tailscale/tsrelay/handler" 21 | "github.com/tailscale-dev/vscode-tailscale/tsrelay/logger" 22 | "tailscale.com/client/tailscale" 23 | ) 24 | 25 | var ( 26 | logfile = flag.String("logfile", "", "send logs to a file instead of stderr") 27 | verbose = flag.Bool("v", false, "verbose logging") 28 | port = flag.Int("port", 0, "port for http server. If 0, one will be chosen") 29 | nonce = flag.String("nonce", "", "nonce for the http server") 30 | socket = flag.String("socket", "", "alternative path for local api socket") 31 | mockFile = flag.String("mockfile", "", "a profile file to mock LocalClient responses") 32 | ) 33 | 34 | var requiresRestart bool 35 | 36 | func main() { 37 | must(run()) 38 | } 39 | 40 | func run() error { 41 | flag.Parse() 42 | var logOut io.Writer = os.Stderr 43 | if *logfile != "" { 44 | f, err := os.Create(*logfile) 45 | if err != nil { 46 | return fmt.Errorf("could not create log file: %w", err) 47 | } 48 | defer f.Close() 49 | logOut = f 50 | } 51 | 52 | lggr := logger.New(logOut, *verbose) 53 | 54 | flatpakID := os.Getenv("FLATPAK_ID") 55 | isFlatpak := os.Getenv("container") == "flatpak" && strings.HasPrefix(flatpakID, "com.visualstudio.code") 56 | if isFlatpak { 57 | lggr.Println("running inside flatpak") 58 | var err error 59 | requiresRestart, err = ensureTailscaledAccessible(lggr, flatpakID) 60 | if err != nil { 61 | return err 62 | } 63 | lggr.Printf("requires restart: %v", requiresRestart) 64 | } 65 | 66 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) 67 | defer cancel() 68 | 69 | return runHTTPServer(ctx, lggr, *port, *nonce) 70 | } 71 | 72 | func ensureTailscaledAccessible(lggr logger.Logger, flatpakID string) (bool, error) { 73 | _, err := os.Stat("/run/tailscale") 74 | if err == nil { 75 | lggr.Println("tailscaled is accessible") 76 | return false, nil 77 | } 78 | if !errors.Is(err, os.ErrNotExist) { 79 | return false, fmt.Errorf("error checking /run/tailscale: %w", err) 80 | } 81 | lggr.Println("running flatpak override") 82 | cmd := exec.Command( 83 | "flatpak-spawn", 84 | "--host", 85 | "flatpak", 86 | "override", 87 | "--user", 88 | flatpakID, 89 | "--filesystem=/run/tailscale", 90 | ) 91 | output, err := cmd.Output() 92 | if err != nil { 93 | return false, fmt.Errorf("error running flatpak override: %s - %w", output, err) 94 | } 95 | return true, nil 96 | } 97 | 98 | type serverDetails struct { 99 | Address string `json:"address,omitempty"` 100 | Nonce string `json:"nonce,omitempty"` 101 | Port string `json:"port,omitempty"` 102 | } 103 | 104 | func runHTTPServer(ctx context.Context, lggr logger.Logger, port int, nonce string) error { 105 | l, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) 106 | if err != nil { 107 | return fmt.Errorf("error listening on port %d: %w", port, err) 108 | } 109 | u, err := url.Parse("http://" + l.Addr().String()) 110 | if err != nil { 111 | return fmt.Errorf("error parsing addr %q: %w", l.Addr().String(), err) 112 | } 113 | if nonce == "" { 114 | nonce = getNonce() 115 | } 116 | sd := serverDetails{ 117 | Address: fmt.Sprintf("http://127.0.0.1:%s", u.Port()), 118 | Port: u.Port(), 119 | Nonce: nonce, 120 | } 121 | json.NewEncoder(os.Stdout).Encode(sd) 122 | var lc handler.LocalClient = &tailscale.LocalClient{ 123 | Socket: *socket, 124 | } 125 | if *mockFile != "" { 126 | lc, err = handler.NewMockClient(*mockFile) 127 | if err != nil { 128 | return fmt.Errorf("error creating mock client: %w", err) 129 | } 130 | } 131 | h := handler.NewHandler(lc, nonce, lggr, requiresRestart) 132 | s := &http.Server{Handler: h} 133 | return serve(ctx, lggr, l, s, time.Second) 134 | } 135 | 136 | func serve(ctx context.Context, lggr logger.Logger, l net.Listener, s *http.Server, timeout time.Duration) error { 137 | serverErr := make(chan error, 1) 138 | go func() { 139 | // Capture ListenAndServe errors such as "port already in use". 140 | // However, when a server is gracefully shutdown, it is safe to ignore errors 141 | // returned from this method (given the select logic below), because 142 | // Shutdown causes ListenAndServe to always return http.ErrServerClosed. 143 | serverErr <- s.Serve(l) 144 | }() 145 | var err error 146 | select { 147 | case <-ctx.Done(): 148 | lggr.Println("received interrupt signal") 149 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 150 | defer cancel() 151 | err = s.Shutdown(ctx) 152 | case err = <-serverErr: 153 | } 154 | return err 155 | } 156 | 157 | func getNonce() string { 158 | const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" 159 | var b strings.Builder 160 | for i := 0; i < 32; i++ { 161 | b.WriteByte(possible[rand.Intn(len(possible))]) 162 | } 163 | return b.String() 164 | } 165 | 166 | func must(err error) { 167 | if err != nil { 168 | panic(err) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { configDefaults, defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | exclude: [...configDefaults.exclude, '**/out/**'], 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /vsc-extension-quickstart.md: -------------------------------------------------------------------------------- 1 | # Welcome to your VS Code Extension 2 | 3 | ## What's in the folder 4 | 5 | - This folder contains all of the files necessary for your extension. 6 | - `package.json` - this is the manifest file in which you declare your extension and command. 7 | - The sample plugin registers a command and defines its title and command name. With this information VS Code can show the command in the command palette. It doesn’t yet need to load the plugin. 8 | - `src/extension.ts` - this is the main file where you will provide the implementation of your command. 9 | - The file exports one function, `activate`, which is called the very first time your extension is activated (in this case by executing the command). Inside the `activate` function we call `registerCommand`. 10 | - We pass the function containing the implementation of the command as the second parameter to `registerCommand`. 11 | 12 | ## Setup 13 | 14 | - install the recommended extensions (amodio.tsl-problem-matcher and dbaeumer.vscode-eslint) 15 | 16 | ## Get up and running straight away 17 | 18 | - Press `F5` to open a new window with your extension loaded. 19 | - Run your command from the command palette by pressing (`Ctrl+Shift+P` or `Cmd+Shift+P` on Mac) and typing `Hello World`. 20 | - Set breakpoints in your code inside `src/extension.ts` to debug your extension. 21 | - Find output from your extension in the debug console. 22 | 23 | ## Make changes 24 | 25 | - You can relaunch the extension from the debug toolbar after changing code in `src/extension.ts`. 26 | - You can also reload (`Ctrl+R` or `Cmd+R` on Mac) the VS Code window with your extension to load your changes. 27 | 28 | ## Explore the API 29 | 30 | - You can open the full set of our API when you open the file `node_modules/@types/vscode/index.d.ts`. 31 | 32 | ## Run tests 33 | 34 | - Open the debug viewlet (`Ctrl+Shift+D` or `Cmd+Shift+D` on Mac) and from the launch configuration dropdown pick `Extension Tests`. 35 | - Press `F5` to run the tests in a new window with your extension loaded. 36 | - See the output of the test result in the debug console. 37 | - Make changes to `src/test/suite/extension.test.ts` or create new test files inside the `test/suite` folder. 38 | - The provided test runner will only consider files matching the name pattern `**.test.ts`. 39 | - You can create folders inside the `test` folder to structure your tests any way you want. 40 | 41 | ## Go further 42 | 43 | - Reduce the extension size and improve the startup time by [bundling your extension](https://code.visualstudio.com/api/working-with-extensions/bundling-extension). 44 | - [Publish your extension](https://code.visualstudio.com/api/working-with-extensions/publishing-extension) on the VS Code extension marketplace. 45 | - Automate builds by setting up [Continuous Integration](https://code.visualstudio.com/api/working-with-extensions/continuous-integration). 46 | -------------------------------------------------------------------------------- /webpack.config.mjs: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import url from 'url'; 3 | 4 | const __filename = url.fileURLToPath(import.meta.url); 5 | const __dirname = path.dirname(__filename); 6 | 7 | const baseConfig = { 8 | mode: process.env.NODE_ENV || 'development', 9 | resolve: { 10 | mainFields: ['browser', 'module', 'main'], 11 | extensions: ['.tsx', '.ts', '.js'], 12 | }, 13 | externals: { 14 | vscode: 'commonjs vscode', 15 | }, 16 | devtool: 'nosources-source-map', 17 | infrastructureLogging: { 18 | level: 'log', // enables logging required for VS Code problem matcher 19 | }, 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.tsx?$/, 24 | use: [ 25 | { 26 | loader: 'ts-loader', 27 | options: { 28 | transpileOnly: true, 29 | }, 30 | }, 31 | ], 32 | exclude: /node_modules/, 33 | }, 34 | { 35 | test: /\.node$/, 36 | loader: 'node-loader', 37 | }, 38 | { 39 | test: /\.css$/i, 40 | use: ['style-loader', 'css-loader', 'postcss-loader'], 41 | }, 42 | ], 43 | }, 44 | }; 45 | 46 | export default () => { 47 | return [ 48 | { 49 | // server-side 50 | ...baseConfig, 51 | entry: './src/extension.ts', 52 | target: 'node', 53 | mode: 'none', 54 | output: { 55 | filename: 'extension.js', 56 | path: path.resolve(__dirname, 'dist'), 57 | libraryTarget: 'commonjs2', 58 | devtoolModuleFilenameTemplate: '../[resource-path]', 59 | }, 60 | }, 61 | { 62 | // client-side 63 | ...baseConfig, 64 | entry: './src/webviews/serve-panel/index.tsx', 65 | target: 'web', 66 | output: { 67 | filename: 'serve-panel.js', 68 | path: path.resolve(__dirname, 'dist'), 69 | }, 70 | devServer: { 71 | port: 8000, 72 | allowedHosts: 'all', 73 | devMiddleware: { 74 | writeToDisk: true, 75 | }, 76 | headers: { 77 | 'Access-Control-Allow-Origin': '*', 78 | 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', 79 | }, 80 | hot: true, 81 | }, 82 | optimization: { 83 | splitChunks: { 84 | cacheGroups: { 85 | default: false, 86 | }, 87 | }, 88 | }, 89 | }, 90 | ]; 91 | }; 92 | -------------------------------------------------------------------------------- /yarn.rev: -------------------------------------------------------------------------------- 1 | 1.22.19 2 | --------------------------------------------------------------------------------