diff --git a/.github/workflows/cifuzz.yml b/.github/workflows/cifuzz.yml deleted file mode 100644 index a75be317a4f84..0000000000000 --- a/.github/workflows/cifuzz.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: CIFuzz -on: [pull_request] - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - Fuzzing: - runs-on: ubuntu-latest - steps: - - name: Build Fuzzers - id: build - uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@master - with: - oss-fuzz-project-name: 'tailscale' - dry-run: false - language: go - - name: Run Fuzzers - uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@master - with: - oss-fuzz-project-name: 'tailscale' - fuzz-seconds: 300 - dry-run: false - language: go - - name: Upload Crash - uses: actions/upload-artifact@v3 - if: failure() && steps.build.outcome == 'success' - with: - name: artifacts - path: ./out/artifacts diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 14ea216f4c5c8..31f77178e362b 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -17,6 +17,8 @@ on: pull_request: # The branches below must be a subset of the branches above branches: [ main ] + merge_group: + branches: [ main ] schedule: - cron: '31 14 * * 5' diff --git a/.github/workflows/cross-android.yml b/.github/workflows/cross-android.yml deleted file mode 100644 index bdcc58bb168ab..0000000000000 --- a/.github/workflows/cross-android.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Android-Cross - -on: - push: - branches: - - main - pull_request: - branches: - - '*' - - 'release-branch/*' - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - - if: "!contains(github.event.head_commit.message, '[ci skip]')" - - steps: - - name: Check out code into the Go module directory - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - id: go - - - name: Android smoke build - # Super minimal Android build that doesn't even use CGO and doesn't build everything that's needed - # and is only arm64. But it's a smoke build: it's not meant to catch everything. But it'll catch - # some Android breakages early. - # TODO(bradfitz): better; see https://github.com/tailscale/tailscale/issues/4482 - env: - GOOS: android - GOARCH: arm64 - run: go install ./net/netns ./ipn/ipnlocal ./wgengine/magicsock/ ./wgengine/ ./wgengine/router/ ./wgengine/netstack ./util/dnsname/ ./ipn/ ./net/interfaces ./wgengine/router/ ./tailcfg/ ./types/logger/ ./net/dns ./hostinfo ./version - - - uses: k0kubun/action-slack@v2.0.0 - with: - payload: | - { - "attachments": [{ - "text": "${{ job.status }}: ${{ github.workflow }} " + - "() " + - "of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}", - "color": "danger" - }] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - if: failure() && github.event_name == 'push' diff --git a/.github/workflows/cross-darwin.yml b/.github/workflows/cross-darwin.yml deleted file mode 100644 index 0e632e6dee44a..0000000000000 --- a/.github/workflows/cross-darwin.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Darwin-Cross - -on: - push: - branches: - - main - pull_request: - branches: - - '*' - - 'release-branch/*' - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - - if: "!contains(github.event.head_commit.message, '[ci skip]')" - - steps: - - name: Check out code into the Go module directory - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - id: go - - - name: macOS build cmd - env: - GOOS: darwin - GOARCH: amd64 - run: go build ./cmd/... - - - name: macOS build tests - env: - GOOS: darwin - GOARCH: amd64 - run: for d in $(go list -f '{{if .TestGoFiles}}{{.Dir}}{{end}}' ./... ); do (echo $d; cd $d && go test -c ); done - - - name: iOS build most - env: - GOOS: ios - GOARCH: arm64 - run: go install ./ipn/... ./wgengine/ ./types/... ./control/controlclient - - - uses: k0kubun/action-slack@v2.0.0 - with: - payload: | - { - "attachments": [{ - "text": "${{ job.status }}: ${{ github.workflow }} " + - "() " + - "of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}", - "color": "danger" - }] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - if: failure() && github.event_name == 'push' diff --git a/.github/workflows/cross-freebsd.yml b/.github/workflows/cross-freebsd.yml deleted file mode 100644 index 077c7f95a7f1f..0000000000000 --- a/.github/workflows/cross-freebsd.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: FreeBSD-Cross - -on: - push: - branches: - - main - pull_request: - branches: - - '*' - - 'release-branch/*' - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - - if: "!contains(github.event.head_commit.message, '[ci skip]')" - - steps: - - name: Check out code into the Go module directory - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - id: go - - - name: FreeBSD build cmd - env: - GOOS: freebsd - GOARCH: amd64 - run: go build ./cmd/... - - - name: FreeBSD build tests - env: - GOOS: freebsd - GOARCH: amd64 - run: for d in $(go list -f '{{if .TestGoFiles}}{{.Dir}}{{end}}' ./... ); do (echo $d; cd $d && go test -c ); done - - - uses: k0kubun/action-slack@v2.0.0 - with: - payload: | - { - "attachments": [{ - "text": "${{ job.status }}: ${{ github.workflow }} " + - "() " + - "of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}", - "color": "danger" - }] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - if: failure() && github.event_name == 'push' diff --git a/.github/workflows/cross-loong64.yml b/.github/workflows/cross-loong64.yml deleted file mode 100644 index 9ee70059aed16..0000000000000 --- a/.github/workflows/cross-loong64.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Loongnix-Cross - -on: - push: - branches: - - main - pull_request: - branches: - - '*' - - 'release-branch/*' - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - - if: "!contains(github.event.head_commit.message, '[ci skip]')" - - steps: - - name: Check out code into the Go module directory - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - id: go - - - name: Loongnix build cmd - env: - GOOS: linux - GOARCH: loong64 - run: go build ./cmd/... - - - name: Loongnix build tests - env: - GOOS: linux - GOARCH: loong64 - run: for d in $(go list -f '{{if .TestGoFiles}}{{.Dir}}{{end}}' ./... ); do (echo $d; cd $d && go test -c ); done - - - uses: k0kubun/action-slack@v2.0.0 - with: - payload: | - { - "attachments": [{ - "text": "${{ job.status }}: ${{ github.workflow }} " + - "() " + - "of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}", - "color": "danger" - }] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - if: failure() && github.event_name == 'push' diff --git a/.github/workflows/cross-openbsd.yml b/.github/workflows/cross-openbsd.yml deleted file mode 100644 index 04ba424229ce4..0000000000000 --- a/.github/workflows/cross-openbsd.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: OpenBSD-Cross - -on: - push: - branches: - - main - pull_request: - branches: - - '*' - - 'release-branch/*' - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - - if: "!contains(github.event.head_commit.message, '[ci skip]')" - - steps: - - name: Check out code into the Go module directory - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - id: go - - - name: OpenBSD build cmd - env: - GOOS: openbsd - GOARCH: amd64 - run: go build ./cmd/... - - - name: OpenBSD build tests - env: - GOOS: openbsd - GOARCH: amd64 - run: for d in $(go list -f '{{if .TestGoFiles}}{{.Dir}}{{end}}' ./... ); do (echo $d; cd $d && go test -c ); done - - - uses: k0kubun/action-slack@v2.0.0 - with: - payload: | - { - "attachments": [{ - "text": "${{ job.status }}: ${{ github.workflow }} " + - "() " + - "of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}", - "color": "danger" - }] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - if: failure() && github.event_name == 'push' diff --git a/.github/workflows/cross-wasm.yml b/.github/workflows/cross-wasm.yml deleted file mode 100644 index d729fea8b8138..0000000000000 --- a/.github/workflows/cross-wasm.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Wasm-Cross - -on: - push: - branches: - - main - pull_request: - branches: - - '*' - - 'release-branch/*' - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - - if: "!contains(github.event.head_commit.message, '[ci skip]')" - - steps: - - name: Check out code into the Go module directory - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - id: go - - - name: Wasm client build - env: - GOOS: js - GOARCH: wasm - run: go build ./cmd/tsconnect/wasm ./cmd/tailscale/cli - - - name: tsconnect static build - # Use our custom Go toolchain, we set build tags (to control binary size) - # that depend on it. - run: | - ./tool/go run ./cmd/tsconnect --fast-compression build - ./tool/go run ./cmd/tsconnect --fast-compression build-pkg - - - uses: k0kubun/action-slack@v2.0.0 - with: - payload: | - { - "attachments": [{ - "text": "${{ job.status }}: ${{ github.workflow }} " + - "() " + - "of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}", - "color": "danger" - }] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - if: failure() && github.event_name == 'push' diff --git a/.github/workflows/cross-windows.yml b/.github/workflows/cross-windows.yml deleted file mode 100644 index 0b80b349e3ca3..0000000000000 --- a/.github/workflows/cross-windows.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Windows-Cross - -on: - push: - branches: - - main - pull_request: - branches: - - '*' - - 'release-branch/*' - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - - if: "!contains(github.event.head_commit.message, '[ci skip]')" - - steps: - - name: Check out code into the Go module directory - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - id: go - - - name: Windows build cmd - env: - GOOS: windows - GOARCH: amd64 - run: go build ./cmd/... - - - name: Windows build tests - env: - GOOS: windows - GOARCH: amd64 - run: for d in $(go list -f '{{if .TestGoFiles}}{{.Dir}}{{end}}' ./... ); do (echo $d; cd $d && go test -c ); done - - - uses: k0kubun/action-slack@v2.0.0 - with: - payload: | - { - "attachments": [{ - "text": "${{ job.status }}: ${{ github.workflow }} " + - "() " + - "of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}", - "color": "danger" - }] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - if: failure() && github.event_name == 'push' diff --git a/.github/workflows/depaware.yml b/.github/workflows/depaware.yml deleted file mode 100644 index 1610a4a646cf0..0000000000000 --- a/.github/workflows/depaware.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: depaware - -on: - push: - branches: - - main - pull_request: - branches: - - '*' - - 'release-branch/*' - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Check out code - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - - - name: depaware - run: go run github.com/tailscale/depaware --check - tailscale.com/cmd/tailscaled - tailscale.com/cmd/tailscale - tailscale.com/cmd/derper diff --git a/.github/workflows/go-licenses.yml b/.github/workflows/go-licenses.yml index a028f626df0a8..bbad04c9526f6 100644 --- a/.github/workflows/go-licenses.yml +++ b/.github/workflows/go-licenses.yml @@ -17,7 +17,7 @@ concurrency: cancel-in-progress: true jobs: - tailscale: + update-licenses: runs-on: ubuntu-latest steps: @@ -25,7 +25,7 @@ jobs: uses: actions/checkout@v3 - name: Set up Go - uses: actions/setup-go@v3 + uses: actions/setup-go@v4 with: go-version-file: go.mod @@ -42,7 +42,7 @@ jobs: go-licenses report tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled > licenses/tailscale.md --template .github/licenses.tmpl - name: Get access token - uses: tibdex/github-app-token@f717b5ecd4534d3c4df4ce9b5c1c2214f0f7cd06 # v1.6.0 + uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0 id: generate-token with: app_id: ${{ secrets.LICENSING_APP_ID }} @@ -50,7 +50,7 @@ jobs: private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }} - name: Send pull request - uses: peter-evans/create-pull-request@ad43dccb4d726ca8514126628bec209b8354b6dd #v4.1.4 + uses: peter-evans/create-pull-request@38e0b6e68b4c852a5500a94740f0e535e0d7ba54 #v4.2.4 with: token: ${{ steps.generate-token.outputs.token }} author: License Updater diff --git a/.github/workflows/go_generate.yml b/.github/workflows/go_generate.yml deleted file mode 100644 index 6f82af9638e6c..0000000000000 --- a/.github/workflows/go_generate.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: go generate - -on: - push: - branches: - - main - - "release-branch/*" - pull_request: - branches: - - "*" - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - check: - runs-on: ubuntu-latest - - steps: - - name: Check out code - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - - - name: check 'go generate' is clean - run: | - if [[ "${{github.ref}}" == release-branch/* ]] - then - pkgs=$(go list ./... | grep -v dnsfallback) - else - pkgs=$(go list ./... | grep -v dnsfallback) - fi - go generate $pkgs - echo - echo - git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go generate'."; exit 1) diff --git a/.github/workflows/go_mod_tidy.yml b/.github/workflows/go_mod_tidy.yml deleted file mode 100644 index cdf6283ceeba2..0000000000000 --- a/.github/workflows/go_mod_tidy.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: go mod tidy - -on: - push: - branches: - - main - pull_request: - branches: - - "*" - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - check: - runs-on: ubuntu-latest - - steps: - - name: Check out code - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - - - name: check 'go mod tidy' is clean - run: | - ./tool/go mod tidy - echo - echo - git diff --name-only --exit-code || (echo "Please run 'go mod tidy'."; exit 1) diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 0000000000000..571588660489c --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,40 @@ +name: golangci-lint +on: + # For now, only lint pull requests, not the main branches. + pull_request: + + # TODO(andrew): enable for main branch after an initial waiting period. + #push: + # branches: + # - main + + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-go@v3 + with: + go-version-file: go.mod + cache: false + + - name: golangci-lint + # Note: this is the 'v3' tag as of 2023-04-17 + uses: golangci/golangci-lint-action@08e2f20817b15149a52b5b3ebe7de50aff2ba8c5 + with: + version: v1.52.2 + + # Show only new issues if it's a pull request. + only-new-issues: true diff --git a/.github/workflows/license.yml b/.github/workflows/license.yml deleted file mode 100644 index ce242df08af97..0000000000000 --- a/.github/workflows/license.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: license - -on: - push: - branches: - - main - pull_request: - branches: - - '*' - - 'release-branch/*' - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Check out code - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - - - name: Run license checker - run: ./scripts/check_license_headers.sh . - - - uses: k0kubun/action-slack@v2.0.0 - with: - payload: | - { - "attachments": [{ - "text": "${{ job.status }}: ${{ github.workflow }} " + - "() " + - "of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}", - "color": "danger" - }] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - if: failure() && github.event_name == 'push' diff --git a/.github/workflows/linux-race.yml b/.github/workflows/linux-race.yml deleted file mode 100644 index f34acdb7c6fa8..0000000000000 --- a/.github/workflows/linux-race.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Linux race - -on: - push: - branches: - - main - pull_request: - branches: - - '*' - - 'release-branch/*' - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - - if: "!contains(github.event.head_commit.message, '[ci skip]')" - - steps: - - name: Check out code into the Go module directory - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - id: go - - - name: Build test wrapper - run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper - - - name: Basic build - run: go build ./cmd/... - - - name: Run tests and benchmarks with -race flag on linux - run: go test -exec=/tmp/testwrapper -race -bench=. -benchtime=1x ./... - - - name: Check that no tracked files in the repo have been modified - run: git diff --no-ext-diff --name-only --exit-code || (echo "Build/test modified the files above."; exit 1) - - - name: Check that no files have been added to the repo - run: | - # Note: The "error: pathspec..." you see below is normal! - # In the success case in which there are no new untracked files, - # git ls-files complains about the pathspec not matching anything. - # That's OK. It's not worth the effort to suppress. Please ignore it. - if git ls-files --others --exclude-standard --directory --no-empty-directory --error-unmatch -- ':/*' - then - echo "Build/test created untracked files in the repo (file names above)." - exit 1 - fi - - - uses: k0kubun/action-slack@v2.0.0 - with: - payload: | - { - "attachments": [{ - "text": "${{ job.status }}: ${{ github.workflow }} " + - "() " + - "of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}", - "color": "danger" - }] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - if: failure() && github.event_name == 'push' - diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml deleted file mode 100644 index 0f6588c723177..0000000000000 --- a/.github/workflows/linux.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Linux - -on: - push: - branches: - - main - pull_request: - branches: - - '*' - - 'release-branch/*' - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-22.04 - - if: "!contains(github.event.head_commit.message, '[ci skip]')" - - steps: - - name: Check out code into the Go module directory - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - id: go - - - name: Basic build - run: go build ./cmd/... - - - name: Build test wrapper - run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper - - - name: Build variants - run: | - go install --tags=ts_include_cli ./cmd/tailscaled - go install --tags=ts_omit_aws ./cmd/tailscaled - - - name: Get QEMU - run: | - sudo apt-get -y update - sudo apt-get -y install qemu-user - - - name: Run tests on linux - run: go test -exec=/tmp/testwrapper -bench=. -benchtime=1x ./... - - - name: Check that no tracked files in the repo have been modified - run: git diff --no-ext-diff --name-only --exit-code || (echo "Build/test modified the files above."; exit 1) - - - name: Check that no files have been added to the repo - run: | - # Note: The "error: pathspec..." you see below is normal! - # In the success case in which there are no new untracked files, - # git ls-files complains about the pathspec not matching anything. - # That's OK. It's not worth the effort to suppress. Please ignore it. - if git ls-files --others --exclude-standard --directory --no-empty-directory --error-unmatch -- ':/*' - then - echo "Build/test created untracked files in the repo (file names above)." - exit 1 - fi - - - uses: k0kubun/action-slack@v2.0.0 - with: - payload: | - { - "attachments": [{ - "text": "${{ job.status }}: ${{ github.workflow }} " + - "() " + - "of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}", - "color": "danger" - }] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - if: failure() && github.event_name == 'push' - diff --git a/.github/workflows/linux32.yml b/.github/workflows/linux32.yml deleted file mode 100644 index c1f598f78feae..0000000000000 --- a/.github/workflows/linux32.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Linux 32-bit - -on: - push: - branches: - - main - pull_request: - branches: - - '*' - - 'release-branch/*' - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - - if: "!contains(github.event.head_commit.message, '[ci skip]')" - - steps: - - name: Check out code into the Go module directory - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - id: go - - - name: Basic build - run: GOARCH=386 go build ./cmd/... - - - name: Run tests on linux - run: GOARCH=386 go test -bench=. -benchtime=1x ./... - - - name: Check that no tracked files in the repo have been modified - run: git diff --no-ext-diff --name-only --exit-code || (echo "Build/test modified the files above."; exit 1) - - - name: Check that no files have been added to the repo - run: | - # Note: The "error: pathspec..." you see below is normal! - # In the success case in which there are no new untracked files, - # git ls-files complains about the pathspec not matching anything. - # That's OK. It's not worth the effort to suppress. Please ignore it. - if git ls-files --others --exclude-standard --directory --no-empty-directory --error-unmatch -- ':/*' - then - echo "Build/test created untracked files in the repo (file names above)." - exit 1 - fi - - - uses: k0kubun/action-slack@v2.0.0 - with: - payload: | - { - "attachments": [{ - "text": "${{ job.status }}: ${{ github.workflow }} " + - "() " + - "of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}", - "color": "danger" - }] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - if: failure() && github.event_name == 'push' - diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml deleted file mode 100644 index c782715c43262..0000000000000 --- a/.github/workflows/static-analysis.yml +++ /dev/null @@ -1,119 +0,0 @@ -name: static-analysis - -on: - push: - branches: - - main - pull_request: - branches: - - '*' - - 'release-branch/*' - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - gofmt: - runs-on: ubuntu-latest - steps: - - name: Check out code - uses: actions/checkout@v3 - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - - name: Run gofmt (goimports) - run: | - OUT=$(go run golang.org/x/tools/cmd/goimports -d --format-only .) - [ -z "$OUT" ] || (echo "Not gofmt'ed: $OUT" && exit 1) - - uses: k0kubun/action-slack@v2.0.0 - with: - payload: | - { - "attachments": [{ - "text": "${{ job.status }}: ${{ github.workflow }} " + - "() " + - "of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}", - "color": "danger" - }] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - if: failure() && github.event_name == 'push' - - vet: - runs-on: ubuntu-latest - steps: - - name: Check out code - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - - - name: Run go vet - run: go vet ./... - - - uses: k0kubun/action-slack@v2.0.0 - with: - payload: | - { - "attachments": [{ - "text": "${{ job.status }}: ${{ github.workflow }} " + - "() " + - "of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}", - "color": "danger" - }] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - if: failure() && github.event_name == 'push' - - staticcheck: - runs-on: ubuntu-latest - strategy: - matrix: - goos: [linux, windows, darwin] - goarch: [amd64] - include: - - goos: windows - goarch: 386 - - steps: - - name: Check out code - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - - - - name: Install staticcheck - run: "GOBIN=~/.local/bin go install honnef.co/go/tools/cmd/staticcheck" - - - name: Print staticcheck version - run: "staticcheck -version" - - - name: "Run staticcheck (${{ matrix.goos }}/${{ matrix.goarch }})" - env: - GOOS: ${{ matrix.goos }} - GOARCH: ${{ matrix.goarch }} - run: "staticcheck -- $(go list ./... | grep -v tempfork)" - - - uses: k0kubun/action-slack@v2.0.0 - with: - payload: | - { - "attachments": [{ - "text": "${{ job.status }}: ${{ github.workflow }} " + - "() " + - "of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}", - "color": "danger" - }] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - if: failure() && github.event_name == 'push' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000000000..1c38142bc45d5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,492 @@ +# This is our main "CI tests" workflow. It runs everything that should run on +# both PRs and merged commits, and for the latter reports failures to slack. +name: CI + +env: + # Our fuzz job, powered by OSS-Fuzz, fails periodically because we upgrade to + # new Go versions very eagerly. OSS-Fuzz is a little more conservative, and + # ends up being unable to compile our code. + # + # When this happens, we want to disable the fuzz target until OSS-Fuzz catches + # up. However, we also don't want to forget to turn it back on when OSS-Fuzz + # can once again build our code. + # + # This variable toggles the fuzz job between two modes: + # - false: we expect fuzzing to be happy, and should report failure if it's not. + # - true: we expect fuzzing is broken, and should report failure if it start working. + TS_FUZZ_CURRENTLY_BROKEN: false + +on: + push: + branches: + - "main" + - "release-branch/*" + pull_request: + branches: + - "*" + merge_group: + branches: + - "main" + +concurrency: + # For PRs, later CI runs preempt previous ones. e.g. a force push on a PR + # cancels running CI jobs and starts all new ones. + # + # For non-PR pushes, concurrency.group needs to be unique for every distinct + # CI run we want to have happen. Use run_id, which in practice means all + # non-PR CI runs will be allowed to run without preempting each other. + group: ${{ github.workflow }}-$${{ github.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + test: + strategy: + fail-fast: false # don't abort the entire matrix if one element fails + matrix: + include: + - goarch: amd64 + - goarch: amd64 + buildflags: "-race" + - goarch: "386" # thanks yaml + runs-on: ubuntu-22.04 + steps: + - name: checkout + uses: actions/checkout@v3 + - name: Restore Cache + uses: actions/cache@v3 + with: + # Note: unlike the other setups, this is only grabbing the mod download + # cache, rather than the whole mod directory, as the download cache + # contains zips that can be unpacked in parallel faster than they can be + # fetched and extracted by tar + path: | + ~/.cache/go-build + ~/go/pkg/mod/cache + ~\AppData\Local\go-build + # The -2- here should be incremented when the scheme of data to be + # cached changes (e.g. path above changes). + key: ${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2- + - name: build all + run: ./tool/go build ${{matrix.buildflags}} ./... + env: + GOARCH: ${{ matrix.goarch }} + - name: build variant CLIs + run: | + export TS_USE_TOOLCHAIN=1 + ./build_dist.sh --extra-small ./cmd/tailscaled + ./build_dist.sh --box ./cmd/tailscaled + ./build_dist.sh --extra-small --box ./cmd/tailscaled + rm -f tailscaled + env: + GOARCH: ${{ matrix.goarch }} + - name: get qemu # for tstest/archtest + if: matrix.goarch == 'amd64' && matrix.variant == '' + run: | + sudo apt-get -y update + sudo apt-get -y install qemu-user + - name: build test wrapper + run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper + - name: test all + run: ./tool/go test ${{matrix.buildflags}} -exec=/tmp/testwrapper -bench=. -benchtime=1x ./... + env: + GOARCH: ${{ matrix.goarch }} + - name: check that no tracked files changed + run: git diff --no-ext-diff --name-only --exit-code || (echo "Build/test modified the files above."; exit 1) + - name: check that no new files were added + run: | + # Note: The "error: pathspec..." you see below is normal! + # In the success case in which there are no new untracked files, + # git ls-files complains about the pathspec not matching anything. + # That's OK. It's not worth the effort to suppress. Please ignore it. + if git ls-files --others --exclude-standard --directory --no-empty-directory --error-unmatch -- ':/*' + then + echo "Build/test created untracked files in the repo (file names above)." + exit 1 + fi + + windows: + runs-on: windows-2022 + steps: + - name: checkout + uses: actions/checkout@v3 + + - name: Install Go + uses: actions/setup-go@v4 + with: + go-version-file: go.mod + cache: false + + - name: Restore Cache + uses: actions/cache@v3 + with: + # Note: unlike the other setups, this is only grabbing the mod download + # cache, rather than the whole mod directory, as the download cache + # contains zips that can be unpacked in parallel faster than they can be + # fetched and extracted by tar + path: | + ~/.cache/go-build + ~/go/pkg/mod/cache + ~\AppData\Local\go-build + # The -2- here should be incremented when the scheme of data to be + # cached changes (e.g. path above changes). + key: ${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ github.job }}-${{ runner.os }}-go-2- + - name: test + # Don't use -bench=. -benchtime=1x. + # Somewhere in the layers (powershell?) + # the equals signs cause great confusion. + run: go test -bench . -benchtime 1x ./... + + vm: + runs-on: ["self-hosted", "linux", "vm"] + # VM tests run with some privileges, don't let them run on 3p PRs. + if: github.repository == 'tailscale/tailscale' + steps: + - name: checkout + uses: actions/checkout@v3 + - name: Run VM tests + run: ./tool/go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2004 + env: + HOME: "/tmp" + TMPDIR: "/tmp" + XDB_CACHE_HOME: "/var/lib/ghrunner/cache" + + cross: # cross-compile checks, build only. + strategy: + fail-fast: false # don't abort the entire matrix if one element fails + matrix: + include: + # Note: linux/amd64 is not in this matrix, because that goos/goarch is + # tested more exhaustively in the 'test' job above. + - goos: linux + goarch: arm64 + - goos: linux + goarch: "386" # thanks yaml + - goos: linux + goarch: loong64 + - goos: linux + goarch: arm + goarm: "5" + - goos: linux + goarch: arm + goarm: "7" + # macOS + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + # Windows + - goos: windows + goarch: amd64 + - goos: windows + goarch: arm64 + # BSDs + - goos: freebsd + goarch: amd64 + - goos: openbsd + goarch: amd64 + + runs-on: ubuntu-22.04 + steps: + - name: checkout + uses: actions/checkout@v3 + - name: Restore Cache + uses: actions/cache@v3 + with: + # Note: unlike the other setups, this is only grabbing the mod download + # cache, rather than the whole mod directory, as the download cache + # contains zips that can be unpacked in parallel faster than they can be + # fetched and extracted by tar + path: | + ~/.cache/go-build + ~/go/pkg/mod/cache + ~\AppData\Local\go-build + # The -2- here should be incremented when the scheme of data to be + # cached changes (e.g. path above changes). + key: ${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2- + - name: build all + run: ./tool/go build ./cmd/... + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + GOARM: ${{ matrix.goarm }} + CGO_ENABLED: "0" + - name: build tests + run: ./tool/go test -exec=true ./... + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: "0" + + ios: # similar to cross above, but iOS can't build most of the repo. So, just + #make it build a few smoke packages. + runs-on: ubuntu-22.04 + steps: + - name: checkout + uses: actions/checkout@v3 + - name: build some + run: ./tool/go build ./ipn/... ./wgengine/ ./types/... ./control/controlclient + env: + GOOS: ios + GOARCH: arm64 + + android: + # similar to cross above, but android fails to build a few pieces of the + # repo. We should fix those pieces, they're small, but as a stepping stone, + # only test the subset of android that our past smoke test checked. + runs-on: ubuntu-22.04 + steps: + - name: checkout + uses: actions/checkout@v3 + # Super minimal Android build that doesn't even use CGO and doesn't build everything that's needed + # and is only arm64. But it's a smoke build: it's not meant to catch everything. But it'll catch + # some Android breakages early. + # TODO(bradfitz): better; see https://github.com/tailscale/tailscale/issues/4482 + - name: build some + run: ./tool/go install ./net/netns ./ipn/ipnlocal ./wgengine/magicsock/ ./wgengine/ ./wgengine/router/ ./wgengine/netstack ./util/dnsname/ ./ipn/ ./net/interfaces ./wgengine/router/ ./tailcfg/ ./types/logger/ ./net/dns ./hostinfo ./version + env: + GOOS: android + GOARCH: arm64 + + wasm: # builds tsconnect, which is the only wasm build we support + runs-on: ubuntu-22.04 + steps: + - name: checkout + uses: actions/checkout@v3 + - name: Restore Cache + uses: actions/cache@v3 + with: + # Note: unlike the other setups, this is only grabbing the mod download + # cache, rather than the whole mod directory, as the download cache + # contains zips that can be unpacked in parallel faster than they can be + # fetched and extracted by tar + path: | + ~/.cache/go-build + ~/go/pkg/mod/cache + ~\AppData\Local\go-build + # The -2- here should be incremented when the scheme of data to be + # cached changes (e.g. path above changes). + key: ${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ github.job }}-${{ runner.os }}-go-2- + - name: build tsconnect client + run: ./tool/go build ./cmd/tsconnect/wasm ./cmd/tailscale/cli + env: + GOOS: js + GOARCH: wasm + - name: build tsconnect server + # Note, no GOOS/GOARCH in env on this build step, we're running a build + # tool that handles the build itself. + run: | + ./tool/go run ./cmd/tsconnect --fast-compression build + ./tool/go run ./cmd/tsconnect --fast-compression build-pkg + + tailscale_go: # Subset of tests that depend on our custom Go toolchain. + runs-on: ubuntu-22.04 + steps: + - name: checkout + uses: actions/checkout@v3 + - name: test tailscale_go + run: ./tool/go test -tags=tailscale_go,ts_enable_sockstats ./net/sockstats/... + + + fuzz: + # This target periodically breaks (see TS_FUZZ_CURRENTLY_BROKEN at the top + # of the file), so it's more complex than usual: the 'build fuzzers' step + # might fail, and depending on the value of 'TS_FUZZ_CURRENTLY_BROKEN', that + # might or might not be fine. The steps after the build figure out whether + # the success/failure is expected, and appropriately pass/fail the job + # overall accordingly. + # + # Practically, this means that all steps after 'build fuzzers' must have an + # explicit 'if' condition, because the default condition for steps is + # 'success()', meaning "only run this if no previous steps failed". + if: github.event_name == 'pull_request' + runs-on: ubuntu-22.04 + steps: + - name: build fuzzers + id: build + uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@master + # continue-on-error makes steps.build.conclusion be 'success' even if + # steps.build.outcome is 'failure'. This means this step does not + # contribute to the job's overall pass/fail evaluation. + continue-on-error: true + with: + oss-fuzz-project-name: 'tailscale' + dry-run: false + language: go + - name: report unexpectedly broken fuzz build + if: steps.build.outcome == 'failure' && env.TS_FUZZ_CURRENTLY_BROKEN != 'true' + run: | + echo "fuzzer build failed, see above for why" + echo "if the failure is due to OSS-Fuzz not being on the latest Go yet," + echo "set TS_FUZZ_CURRENTLY_BROKEN=true in .github/workflows/test.yml" + echo "to temporarily disable fuzzing until OSS-Fuzz works again." + exit 1 + - name: report unexpectedly working fuzz build + if: steps.build.outcome == 'success' && env.TS_FUZZ_CURRENTLY_BROKEN == 'true' + run: | + echo "fuzzer build succeeded, but we expect it to be broken" + echo "please set TS_FUZZ_CURRENTLY_BROKEN=false in .github/workflows/test.yml" + echo "to reenable fuzz testing" + exit 1 + - name: run fuzzers + id: run + # Run the fuzzers whenever they're able to build, even if we're going to + # report a failure because TS_FUZZ_CURRENTLY_BROKEN is set to the wrong + # value. + if: steps.build.outcome == 'success' + uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@master + with: + oss-fuzz-project-name: 'tailscale' + fuzz-seconds: 300 + dry-run: false + language: go + - name: upload crash + uses: actions/upload-artifact@v3 + if: steps.run.outcome != 'success' && steps.build.outcome == 'success' + with: + name: artifacts + path: ./out/artifacts + + depaware: + runs-on: ubuntu-22.04 + steps: + - name: checkout + uses: actions/checkout@v3 + - name: check depaware + run: | + export PATH=$(./tool/go env GOROOT)/bin:$PATH + find . -name 'depaware.txt' | xargs -n1 dirname | xargs ./tool/go run github.com/tailscale/depaware --check + + go_generate: + runs-on: ubuntu-22.04 + steps: + - name: checkout + uses: actions/checkout@v3 + - name: check that 'go generate' is clean + run: | + pkgs=$(./tool/go list ./... | grep -v dnsfallback) + ./tool/go generate $pkgs + echo + echo + git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go generate'."; exit 1) + + go_mod_tidy: + runs-on: ubuntu-22.04 + steps: + - name: checkout + uses: actions/checkout@v3 + - name: check that 'go mod tidy' is clean + run: | + ./tool/go mod tidy + echo + echo + git diff --name-only --exit-code || (echo "Please run 'go mod tidy'."; exit 1) + + licenses: + runs-on: ubuntu-22.04 + steps: + - name: checkout + uses: actions/checkout@v3 + - name: check licenses + run: ./scripts/check_license_headers.sh . + + staticcheck: + runs-on: ubuntu-22.04 + strategy: + fail-fast: false # don't abort the entire matrix if one element fails + matrix: + goos: ["linux", "windows", "darwin"] + goarch: ["amd64"] + include: + - goos: "windows" + goarch: "386" + steps: + - name: checkout + uses: actions/checkout@v3 + - name: install staticcheck + run: GOBIN=~/.local/bin ./tool/go install honnef.co/go/tools/cmd/staticcheck + - name: run staticcheck + run: | + export GOROOT=$(./tool/go env GOROOT) + export PATH=$GOROOT/bin:$PATH + staticcheck -- $(./tool/go list ./... | grep -v tempfork) + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + + notify_slack: + if: always() + # Any of these jobs failing causes a slack notification. + needs: + - android + - test + - windows + - vm + - cross + - ios + - wasm + - tailscale_go + - fuzz + - depaware + - go_generate + - go_mod_tidy + - licenses + - staticcheck + runs-on: ubuntu-22.04 + steps: + - name: notify + # Only notify slack for merged commits, not PR failures. + # + # It may be tempting to move this condition into the job's 'if' block, but + # don't: Github only collapses the test list into "everything is OK" if + # all jobs succeeded. A skipped job results in the list staying expanded. + # By having the job always run, but skipping its only step as needed, we + # let the CI output collapse nicely in PRs. + if: failure() && github.event_name == 'push' + uses: ruby/action-slack@v3.0.0 + with: + payload: | + { + "attachments": [{ + "title": "Failure: ${{ github.workflow }}", + "title_link": "https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks", + "text": "${{ github.repository }}@${{ github.ref_name }}: ", + "fields": [{ "value": ${{ toJson(github.event.head_commit.message) }}, "short": false }], + "footer": "${{ github.event.head_commit.committer.name }} at ${{ github.event.head_commit.timestamp }}", + "color": "danger" + }] + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + + check_mergeability: + if: always() + runs-on: ubuntu-22.04 + needs: + - android + - test + - windows + - vm + - cross + - ios + - wasm + - tailscale_go + - fuzz + - depaware + - go_generate + - go_mod_tidy + - licenses + - staticcheck + steps: + - name: Decide if change is okay to merge + if: github.event_name != 'push' + uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} diff --git a/.github/workflows/tsconnect-pkg-publish.yml b/.github/workflows/tsconnect-pkg-publish.yml deleted file mode 100644 index 29bd2926e75b9..0000000000000 --- a/.github/workflows/tsconnect-pkg-publish.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: "@tailscale/connect npm publish" - -on: workflow_dispatch - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up node - uses: actions/setup-node@v3 - with: - node-version: "16.x" - registry-url: "https://registry.npmjs.org" - - - name: Build package - # Build with build_dist.sh to ensure that version information is embedded. - # GOROOT is specified so that the Go/Wasm that is trigged by build-pk - # also picks up our custom Go toolchain. - run: | - ./build_dist.sh tailscale.com/cmd/tsconnect - GOROOT="${HOME}/.cache/tailscale-go" ./tsconnect build-pkg - - - name: Publish - env: - NODE_AUTH_TOKEN: ${{ secrets.TSCONNECT_NPM_PUBLISH_AUTH_TOKEN }} - run: ./tool/yarn --cwd ./cmd/tsconnect/pkg publish --access public diff --git a/.github/workflows/update-flake.yml b/.github/workflows/update-flake.yml new file mode 100644 index 0000000000000..438a3d7354797 --- /dev/null +++ b/.github/workflows/update-flake.yml @@ -0,0 +1,49 @@ +name: update-flake + +on: + # run action when a change lands in the main branch which updates go.mod. Also + # allow manual triggering. + push: + branches: + - main + paths: + - go.mod + - .github/workflows/update-flakes.yml + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + update-flake: + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v3 + + - name: Run update-flakes + run: ./update-flake.sh + + - name: Get access token + uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0 + id: generate-token + with: + app_id: ${{ secrets.LICENSING_APP_ID }} + installation_id: ${{ secrets.LICENSING_APP_INSTALLATION_ID }} + private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }} + + - name: Send pull request + uses: peter-evans/create-pull-request@38e0b6e68b4c852a5500a94740f0e535e0d7ba54 #v4.2.4 + with: + token: ${{ steps.generate-token.outputs.token }} + author: Flakes Updater + committer: Flakes Updater + branch: flakes + commit-message: "go.mod.sri: update SRI hash for go.mod changes" + title: "go.mod.sri: update SRI hash for go.mod changes" + body: Triggered by ${{ github.repository }}@${{ github.sha }} + signoff: true + delete-branch: true + reviewers: danderson diff --git a/.github/workflows/vm.yml b/.github/workflows/vm.yml deleted file mode 100644 index a542a82fd94b4..0000000000000 --- a/.github/workflows/vm.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: VM - -on: - pull_request: - branches: - - '*' - - 'release-branch/*' - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - ubuntu2004-LTS-cloud-base: - runs-on: [ self-hosted, linux, vm ] - - if: "(github.repository == 'tailscale/tailscale') && !contains(github.event.head_commit.message, '[ci skip]')" - - steps: - - name: Set GOPATH - run: echo "GOPATH=$HOME/go" >> $GITHUB_ENV - - - name: Checkout Code - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - - - name: Run VM tests - run: go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2004 - env: - HOME: "/tmp" - TMPDIR: "/tmp" - XDG_CACHE_HOME: "/var/lib/ghrunner/cache" - - - uses: k0kubun/action-slack@v2.0.0 - with: - payload: | - { - "attachments": [{ - "text": "${{ job.status }}: ${{ github.workflow }} " + - "() " + - "of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}", - "color": "danger" - }] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - if: failure() && github.event_name == 'push' diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml deleted file mode 100644 index c98ac22a6e577..0000000000000 --- a/.github/workflows/windows.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Windows - -on: - push: - branches: - - main - pull_request: - branches: - - '*' - - 'release-branch/*' - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - test: - runs-on: windows-latest - - if: "!contains(github.event.head_commit.message, '[ci skip]')" - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Install Go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - - - name: Restore Cache - uses: actions/cache@v3 - with: - # Note: unlike some other setups, this is only grabbing the mod download - # cache, rather than the whole mod directory, as the download cache - # contains zips that can be unpacked in parallel faster than they can be - # fetched and extracted by tar - path: | - ~/go/pkg/mod/cache - ~\AppData\Local\go-build - - # The -2- here should be incremented when the scheme of data to be - # cached changes (e.g. path above changes). - # TODO(raggi): add a go version here. - key: ${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }} - - - name: Test - # Don't use -bench=. -benchtime=1x. - # Somewhere in the layers (powershell?) - # the equals signs cause great confusion. - run: go test -bench . -benchtime 1x ./... - - - uses: k0kubun/action-slack@v2.0.0 - with: - payload: | - { - "attachments": [{ - "text": "${{ job.status }}: ${{ github.workflow }} " + - "() " + - "of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}", - "color": "danger" - }] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - if: failure() && github.event_name == 'push' - diff --git a/.gitignore b/.gitignore index 1b08720e8f0ce..a613c538de8f3 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,14 @@ cmd/tailscaled/tailscaled # Ignore personal VS Code settings .vscode/ +# Support personal project-specific GOPATH +.gopath/ + +# Ignore nix build result path +/result + # Ignore direnv nix-shell environment cache .direnv/ + +/gocross +/dist diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000000000..d752fd3ee7738 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,33 @@ +linters: + # Don't enable any linters by default; just the ones that we explicitly + # enable in the list below. + disable-all: true + enable: + - gofmt + - goimports + - misspell + +# Configuration for how we run golangci-lint +run: + timeout: 5m + +issues: + # Excluding configuration per-path, per-linter, per-text and per-source + exclude-rules: + # These are forks of an upstream package and thus are exempt from stylistic + # changes that would make pulling in upstream changes harder. + - path: tempfork/.*\.go + text: "File is not `gofmt`-ed with `-s` `-r 'interface{} -> any'`" + - path: util/singleflight/.*\.go + text: "File is not `gofmt`-ed with `-s` `-r 'interface{} -> any'`" + +# Per-linter settings are contained in this top-level key +linters-settings: + gofmt: + rewrite-rules: + - pattern: 'interface{}' + replacement: 'any' + + goimports: + + misspell: diff --git a/Dockerfile b/Dockerfile index 9c6b2bd6d88e0..12df5f5d8dedd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -62,9 +62,9 @@ ENV VERSION_GIT_HASH=$VERSION_GIT_HASH ARG TARGETARCH RUN GOARCH=$TARGETARCH go install -ldflags="\ - -X tailscale.com/version.Long=$VERSION_LONG \ - -X tailscale.com/version.Short=$VERSION_SHORT \ - -X tailscale.com/version.GitCommit=$VERSION_GIT_HASH" \ + -X tailscale.com/version.longStamp=$VERSION_LONG \ + -X tailscale.com/version.shortStamp=$VERSION_SHORT \ + -X tailscale.com/version.gitCommitStamp=$VERSION_GIT_HASH" \ -v ./cmd/tailscale ./cmd/tailscaled ./cmd/containerboot FROM alpine:3.16 diff --git a/Makefile b/Makefile index 9a6bf51aca392..1c26f04518846 100644 --- a/Makefile +++ b/Makefile @@ -2,16 +2,13 @@ IMAGE_REPO ?= tailscale/tailscale SYNO_ARCH ?= "amd64" SYNO_DSM ?= "7" -usage: - echo "See Makefile" - -vet: +vet: ## Run go vet ./tool/go vet ./... -tidy: +tidy: ## Run go mod tidy ./tool/go mod tidy -updatedeps: +updatedeps: ## Update depaware deps # depaware (via x/tools/go/packages) shells back to "go", so make sure the "go" # it finds in its $$PATH is the right one. PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --update \ @@ -19,7 +16,7 @@ updatedeps: tailscale.com/cmd/tailscale \ tailscale.com/cmd/derper -depaware: +depaware: ## Run depaware checks # depaware (via x/tools/go/packages) shells back to "go", so make sure the "go" # it finds in its $$PATH is the right one. PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --check \ @@ -27,42 +24,42 @@ depaware: tailscale.com/cmd/tailscale \ tailscale.com/cmd/derper -buildwindows: +buildwindows: ## Build tailscale CLI for windows/amd64 GOOS=windows GOARCH=amd64 ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled -build386: +build386: ## Build tailscale CLI for linux/386 GOOS=linux GOARCH=386 ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled -buildlinuxarm: +buildlinuxarm: ## Build tailscale CLI for linux/arm GOOS=linux GOARCH=arm ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled -buildwasm: +buildwasm: ## Build tailscale CLI for js/wasm GOOS=js GOARCH=wasm ./tool/go install ./cmd/tsconnect/wasm ./cmd/tailscale/cli -buildlinuxloong64: +buildlinuxloong64: ## Build tailscale CLI for linux/loong64 GOOS=linux GOARCH=loong64 ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled -buildmultiarchimage: +buildmultiarchimage: ## Build (and optionally push) multiarch docker image ./build_docker.sh -check: staticcheck vet depaware buildwindows build386 buildlinuxarm buildwasm +check: staticcheck vet depaware buildwindows build386 buildlinuxarm buildwasm ## Perform basic checks and compilation tests -staticcheck: +staticcheck: ## Run staticcheck.io checks ./tool/go run honnef.co/go/tools/cmd/staticcheck -- $$(./tool/go list ./... | grep -v tempfork) -spk: +spk: ## Build synology package for ${SYNO_ARCH} architecture and ${SYNO_DSM} DSM version PATH="${PWD}/tool:${PATH}" ./tool/go run github.com/tailscale/tailscale-synology@main -o tailscale.spk --source=. --goarch=${SYNO_ARCH} --dsm-version=${SYNO_DSM} -spkall: +spkall: ## Build synology packages for all architectures and DSM versions mkdir -p spks PATH="${PWD}/tool:${PATH}" ./tool/go run github.com/tailscale/tailscale-synology@main -o spks --source=. --goarch=all --dsm-version=all -pushspk: spk +pushspk: spk ## Push and install synology package on ${SYNO_HOST} host echo "Pushing SPK to root@${SYNO_HOST} (env var SYNO_HOST) ..." scp tailscale.spk root@${SYNO_HOST}: ssh root@${SYNO_HOST} /usr/syno/bin/synopkg install tailscale.spk -publishdevimage: +publishdevimage: ## Build and publish tailscale image to location specified by ${REPO} @test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1) @test "${REPO}" != "tailscale/tailscale" || (echo "REPO=... must not be tailscale/tailscale" && exit 1) @test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1) @@ -70,10 +67,18 @@ publishdevimage: @test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1) TAGS=latest REPOS=${REPO} PUSH=true TARGET=client ./build_docker.sh -publishdevoperator: +publishdevoperator: ## Build and publish k8s-operator image to location specified by ${REPO} @test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1) @test "${REPO}" != "tailscale/tailscale" || (echo "REPO=... must not be tailscale/tailscale" && exit 1) @test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1) @test "${REPO}" != "tailscale/k8s-operator" || (echo "REPO=... must not be tailscale/k8s-operator" && exit 1) @test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1) TAGS=latest REPOS=${REPO} PUSH=true TARGET=operator ./build_docker.sh + +help: ## Show this help + @echo "\nSpecify a command. The choices are:\n" + @grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-20s\033[m %s\n", $$1, $$2}' + @echo "" +.PHONY: help + +.DEFAULT_GOAL := help diff --git a/VERSION.txt b/VERSION.txt index bf50e910e6237..5edffce6d570b 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.37.0 +1.39.0 diff --git a/api.md b/api.md index 88222d1822ff1..670f54d5bbf04 100644 --- a/api.md +++ b/api.md @@ -1,119 +1,225 @@ # Tailscale API -The Tailscale API is a (mostly) RESTful API. Typically, POST bodies should be JSON encoded and responses will be JSON encoded. +The Tailscale API is a (mostly) RESTful API. Typically, both `POST` bodies and responses are JSON-encoded. -# Authentication -Currently based on {some authentication method}. Visit the [admin console](https://login.tailscale.com/admin) and navigate to the `Settings` page. Generate an API Key and keep it safe. Provide the key as the user key in basic auth when making calls to Tailscale API endpoints (leave the password blank). +## Base URL -# APIs +The base URL for the Tailscale API is `https://api.tailscale.com/api/v2/`. -* **[Devices](#device)** - - [GET device](#device-get) - - [DELETE device](#device-delete) - - Routes - - [GET device routes](#device-routes-get) - - [POST device routes](#device-routes-post) - - Authorize machine - - [POST device authorized](#device-authorized-post) - - Tags - - [POST device tags](#device-tags-post) - - Key - - [POST device key](#device-key-post) -* **[Tailnets](#tailnet)** - - ACLs - - [GET tailnet ACL](#tailnet-acl-get) - - [POST tailnet ACL](#tailnet-acl-post) - - [POST tailnet ACL preview](#tailnet-acl-preview-post) - - [POST tailnet ACL validate](#tailnet-acl-validate-post) - - [Devices](#tailnet-devices) - - [GET tailnet devices](#tailnet-devices-get) - - [Keys](#tailnet-keys) - - [GET tailnet keys](#tailnet-keys-get) - - [POST tailnet key](#tailnet-keys-post) - - [GET tailnet key](#tailnet-keys-key-get) - - [DELETE tailnet key](#tailnet-keys-key-delete) - - [DNS](#tailnet-dns) - - [GET tailnet DNS nameservers](#tailnet-dns-nameservers-get) - - [POST tailnet DNS nameservers](#tailnet-dns-nameservers-post) - - [GET tailnet DNS preferences](#tailnet-dns-preferences-get) - - [POST tailnet DNS preferences](#tailnet-dns-preferences-post) - - [GET tailnet DNS searchpaths](#tailnet-dns-searchpaths-get) - - [POST tailnet DNS searchpaths](#tailnet-dns-searchpaths-post) - -## Device - -Each Tailscale-connected device has a globally-unique identifier number which we refer as the "deviceID" or sometimes, just "id". -You can use the deviceID to specify operations on a specific device, like retrieving its subnet routes. - -To find the deviceID of a particular device, you can use the ["GET /devices"](#getdevices) API call and generate a list of devices on your network. -Find the device you're looking for and get the "id" field. -This is your deviceID. - - - -#### `GET /api/v2/device/:deviceid` - lists the details for a device -Returns the details for the specified device. -Supply the device of interest in the path using its ID. -Use the `fields` query parameter to explicitly indicate which fields are returned. - - -##### Parameters -##### Query Parameters -`fields` - Controls which fields will be included in the returned response. -Currently, supported options are: -* `all`: returns all fields in the response. -* `default`: return all fields except: - * `enabledRoutes` - * `advertisedRoutes` - * `clientConnectivity` (which contains the following fields: `mappingVariesByDestIP`, `derp`, `endpoints`, `latency`, and `clientSupports`) +Examples in this document may abbreviate this to `/api/v2/`. -Use commas to separate multiple options. -If more than one option is indicated, then the union is used. -For example, for `fields=default,all`, all fields are returned. -If the `fields` parameter is not provided, then the default option is used. +## Authentication -##### Example -``` -GET /api/v2/device/12345 -curl 'https://api.tailscale.com/api/v2/device/12345?fields=all' \ - -u "tskey-yourapikey123:" +Requests to the Tailscale API are authenticated with an API access token (sometimes called an API key). +Access tokens can be supplied as the username portion of HTTP Basic authentication (leave the password blank) or as an OAuth Bearer token: + +``` sh +# passing token with basic auth +curl -u "tskey-api-xxxxx:" https://api.tailscale.com/api/v2/... + +# passing token as bearer token +curl -H "Authorization: Bearer tskey-api-xxxxx" https://api.tailscale.com/api/v2/... ``` -Response +Access tokens for individual users can be created and managed from the [**Keys**](https://login.tailscale.com/admin/settings/keys) page of the admin console. +These tokens will have the same permissions as the owning user, and can be set to expire in 1 to 90 days. +Access tokens are identifiable by the prefix `tskey-api-`. + +Alternatively, an OAuth client can be used to create short-lived access tokens with scoped permission. +OAuth clients don't expire, and can therefore be used to provide ongoing access to the API, creating access tokens as needed. +OAuth clients and the access tokens they create are not tied to an individual Tailscale user. +OAuth client secrets are identifiable by the prefix `tskey-client-`. +Learn more about [OAuth clients](https://tailscale.com/kb/1215/). + +## Errors + +The Tailscale API returns status codes consistent with [standard HTTP conventions](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status). +In addition to the status code, errors may include additional information in the response body: + +``` jsonc +{ + "message": "additional error information" +} ``` + +## Pagination + +The Tailscale API does not currently support pagination. All results are returned at once. + +# APIs + +**[Device](#device)** +- Get a device: [`GET /api/v2/device/{deviceid}`](#get-device) +- Delete a device: [`DELETE /api/v2/device/{deviceID}`](#delete-device) +- **Routes** + - Get device routes: [`GET /api/v2/device/{deviceID}/routes`](#get-device-routes) + - Set device routes: [`POST /api/v2/device/{deviceID}/routes`](#set-device-routes) +- **Authorize** + - Authorize a device: [`POST /api/v2/device/{deviceID}/authorized`](#authorize-device) +- **Tags** + - Update tags: [`POST /api/v2/device/{deviceID}/tags`](#update-device-tags) +- **Key** + - Update device key: [`POST /api/v2/device/{deviceID}/key`](#update-device-key) + +**[Tailnet](#tailnet)** +- [**Policy File**](#policy-file) + - Get policy file: [`GET /api/v2/tailnet/{tailnet}/acl`](#get-policy-file) + - Update policy file: [`POST /api/v2/tailnet/{tailnet}/acl`](#update-policy-file) + - Preview rule matches: [`POST /api/v2/tailnet/{tailnet}/acl/preview`](#preview-policy-file-rule-matches) + - Validate and test policy file: [`POST /api/v2/tailnet/{tailnet}/acl/validate`](#validate-and-test-policy-file) +- Devices + - List tailnet devices: [`GET /api/v2/tailnet/{tailnet}/devices`](#list-tailnet-devices) +- [**Keys**](#tailnet-keys) + - List tailnet keys: [`GET /api/v2/tailnet/{tailnet}/keys`](#list-tailnet-keys) + - Create an auth key: [`POST /api/v2/tailnet/{tailnet}/keys`](#create-auth-key) + - Get a key: [`GET /api/v2/tailnet/{tailnet}/keys/{keyid}`](#get-key) + - Delete a key: [`DELETE /api/v2/tailnet/{tailnet}/keys/{keyid}`](#delete-key) +- [**DNS**](#dns) + - **Nameservers** + - Get nameservers: [`GET /api/v2/tailnet/{tailnet}/dns/nameservers`](#get-nameservers) + - Set nameservers: [`POST /api/v2/tailnet/{tailnet}/dns/nameservers`](#set-nameservers) + - **Preferences** + - Get DNS preferences: [`GET /api/v2/tailnet/{tailnet}/dns/preferences`](#get-dns-preferences) + - Set DNS preferences: [`POST /api/v2/tailnet/{tailnet}/dns/preferences`](#set-dns-preferences) + - **Search paths** + - Get search paths: [`GET /api/v2/tailnet/{tailnet}/dns/searchpaths](#get-search-paths) + - Set search paths: [`POST /api/v2/tailnet/{tailnet}/dns/searchpaths`](#set-search-paths) + +# Device + +A Tailscale device (sometimes referred to as _node_ or _machine_), is any computer or mobile device that joins a tailnet. + +Each device has a unique ID (`nodeId` in the JSON below) that is used to identify the device in API calls. +This ID can be found by going to the [**Machines**](https://login.tailscale.com/admin/machines) page in the admin console, +selecting the relevant device, then finding the ID in the Machine Details section. +You can also [list all devices in the tailnet](#list-tailnet-devices) to get their `nodeId` values. + +(A device's numeric `id` value can also be used in API calls, but `nodeId` is preferred.) + +### Attributes + +``` jsonc { - "addresses":[ - "100.105.58.116" + // addresses (array of strings) is a list of Tailscale IP + // addresses for the device, including both ipv4 (formatted as 100.x.y.z) + // and ipv6 (formatted as fd7a:115c:a1e0:a:b:c:d:e) addresses. + "addresses": [ + "100.87.74.78", + "fd7a:115c:a1e0:ac82:4843:ca90:697d:c36e" ], - "id":"12345", - "user":"user1@example.com", - "name":"user1-device.example.com", - "hostname":"User1-Device", - "clientVersion":"date.20201107", - "updateAvailable":false, - "os":"macOS", - "created":"2020-11-20T20:56:49Z", - "lastSeen":"2020-11-20T16:15:55-05:00", - "keyExpiryDisabled":false, - "expires":"2021-05-19T20:56:49Z", - "authorized":true, - "isExternal":false, - "machineKey":"mkey:user1-machine-key", - "nodeKey":"nodekey:user1-node-key", - "blocksIncomingConnections":false, - "enabledRoutes":[ + // id (string) is the legacy identifier for a device; you + // can supply this value wherever {deviceId} is indicated in the + // endpoint. Note that although "id" is still accepted, "nodeId" is + // preferred. + "id": "393735751060", + + // nodeID (string) is the preferred identifier for a device; + // supply this value wherever {deviceId} is indicated in the endpoint. + "nodeId": "n5SUKe8CNTRL", + + // user (string) is the user who registered the node. For untagged nodes, + // this user is the device owner. + "user": "amelie@example.com", + + // name (string) is the MagicDNS name of the device. + // Learn more about MagicDNS at https://tailscale.com/kb/1081/. + "name": "pangolin.tailfe8c.ts.net", + + // hostname (string) is the machine name in the admin console + // Learn more about machine names at https://tailscale.com/kb/1098/. + "hostname": "pangolin", + + // clientVersion (string) is the version of the Tailscale client + // software; this is empty for external devices. + "clientVersion": "", + + // updateAvailable (boolean) is 'true' if a Tailscale client version + // upgrade is available. This value is empty for external devices. + "updateAvailable": false, + + // os (string) is the operating system that the device is running. + "os": "linux", + + // created (string) is the date on which the device was added + // to the tailnet; this is empty for external devices. + "created": "2022-12-01T05:23:30Z", + + // lastSeen (string) is when device was last active on the tailnet. + "lastSeen": "2022-12-01T05:23:30Z", + + // keyExpiryDisabled (boolean) is 'true' if the keys for the device + // will not expire. Learn more at https://tailscale.com/kb/1028/. + "keyExpiryDisabled": true, + + // expires (string) is the expiration date of the device's auth key. + // Learn more about key expiry at https://tailscale.com/kb/1028/. + "expires": "2023-05-30T04:44:05Z", + + // authorized (boolean) is 'true' if the device has been + // authorized to join the tailnet; otherwise, 'false'. Learn + // more about device authorization at https://tailscale.com/kb/1099/. + "authorized": true, + + // isExternal (boolean) if 'true', indicates that a device is not + // a member of the tailnet, but is shared in to the tailnet; + // if 'false', the device is a member of the tailnet. + // Learn more about node sharing at https://tailscale.com/kb/1084/. + "isExternal": true, + + // machineKey (string) is for internal use and is not required for + // any API operations. This value is empty for external devices. + "machineKey": "", + + // nodeKey (string) is mostly for internal use, required for select + // operations, such as adding a node to a locked tailnet. + // Learn about tailnet locks at https://tailscale.com/kb/1226/. + "nodeKey": "nodekey:01234567890abcdef", + + // blocksIncomingConnections (boolean) is 'true' if the device is not + // allowed to accept any connections over Tailscale, including pings. + // Learn more in the "Allow incoming connections" + // section of https://tailscale.com/kb/1072/. + "blocksIncomingConnections": false, + + // enabledRoutes (array of strings) are the subnet routes for this + // device that have been approved by the tailnet admin. + // Learn more about subnet routes at https://tailscale.com/kb/1019/. + "enabledRoutes" : [ + "10.0.0.0/16", + "192.168.1.0/24", ], - "advertisedRoutes":[ + // advertisedRoutes (array of strings) are the subnets this device + // intends to expose. + // Learn more about subnet routes at https://tailscale.com/kb/1019/. + "advertisedRoutes" : [ + "10.0.0.0/16", + "192.168.1.0/24", ], + + // clientConnectivity provides a report on the device's current physical + // network conditions. "clientConnectivity": { + + // endpoints (array of strings) Client's magicsock UDP IP:port + // endpoints (IPv4 or IPv6) "endpoints":[ - "209.195.87.231:59128", - "192.168.0.173:59128" + "199.9.14.201:59128", + "192.68.0.21:59128" ], + + // derp (string) is the IP:port of the DERP server currently being used. + // Learn about DERP servers at https://tailscale.com/kb/1232/. "derp":"", + + // mappingVariesByDestIP (boolean) is 'true' if the host's NAT mappings + // vary based on the destination IP. "mappingVariesByDestIP":false, + + // latency (JSON object) lists DERP server locations and their current + // latency; "preferred" is 'true' for the node's preferred DERP + // server for incoming traffic. "latency":{ "Dallas":{ "latencyMs":60.463043 @@ -122,279 +228,502 @@ Response "preferred":true, "latencyMs":31.323811 }, - "San Francisco":{ - "latencyMs":81.313389 - } }, + + // clientSupports (JSON object) identifies features supported by the client. "clientSupports":{ + + // hairpinning (boolean) is 'true' if your router can route connections + // from endpoints on your LAN back to your LAN using those endpoints’ + // globally-mapped IPv4 addresses/ports "hairPinning":false, + + // ipv6 (boolean) is 'true' if the device OS supports IPv6, + // regardless of whether IPv6 internet connectivity is available. "ipv6":false, + + // pcp (boolean) is 'true' if PCP port-mapping service exists on + // your router. "pcp":false, + + // pmp (boolean) is 'true' if NAT-PMP port-mapping service exists + // on your router. "pmp":false, + + // udp (boolean) is 'true' if UDP traffic is enabled on the + // current network; if 'false', Tailscale may be unable to make + // direct connections, and will rely on our DERP servers. "udp":true, + + // upnp (boolean) is 'true' if UPnP port-mapping service exists + // on your router. "upnp":false - } - } + }, + }, + + // tags (array of strings) let you assign an identity to a device that + // is separate from human users, and use it as part of an ACL to restrict + // access. Once a device is tagged, the tag is the owner of that device. + // A single node can have multiple tags assigned. This value is empty for + // external devices. + // Learn more about tags at https://tailscale.com/kb/1068/. + "tags": [ + "tag:golink" + ], + + // tailnetLockError (string) indicates an issue with the tailnet lock + // node-key signature on this device. + // This field is only populated when tailnet lock is enabled. + "tailnetLockError": "", + + // tailnetLockKey (string) is the node's tailnet lock key. Every node + // generates a tailnet lock key (so the value will be present) even if + // tailnet lock is not enabled. + // Learn more about tailnet lock at https://tailscale.com/kb/1226/. + "tailnetLockKey": "", } ``` - +### Subnet routes + +Devices within a tailnet can be set up as subnet routers. +A subnet router acts as a gateway, relaying traffic from your Tailscale network onto your physical subnet. +Setting up subnet routers exposes routes to other devices in the tailnet. +Learn more about [subnet routers](https://tailscale.com/kb/1019). + +A device can act as a subnet router if its subnet routes are both advertised and enabled. +This is a two-step process, but the steps can occur in any order: +- The device that intends to act as a subnet router exposes its routes by **advertising** them. + This is done in the Tailscale command-line interface. +- The tailnet admin must approve the routes by **enabling** them. + This is done in the [**Machines**](https://login.tailscale.com/admin/machines) page of the Tailscale admin console + or [via the API](#set-device-routes). + +If a device has advertised routes, they are not exposed to traffic until they are enabled by the tailnet admin. +Conversely, if a tailnet admin pre-approves certain routes by enabling them, they are not available for routing until the device in question has advertised them. + +The API exposes two methods for dealing with subnet routes: + - Get routes: [`GET /api/v2/device/{deviceID}/routes`](#get-device-routes) to fetch lists of advertised and enabled routes for a device + - Set routes: [`POST /api/v2/device/{deviceID}/routes`](#set-device-routes) to set enabled routes for a device + + + +## Get device + +``` http +GET /api/v2/device/{deviceid} +``` + +Retrieve the details for the specified device. +This returns a JSON `device` object listing device attributes. -#### `DELETE /api/v2/device/:deviceID` - deletes the device from its tailnet -Deletes the provided device from its tailnet. +### Parameters + +#### `deviceid` (required in URL path) + +The ID of the device. + +#### `fields` (optional in query string) + +Controls whether the response returns **all** object fields or only a predefined subset of fields. +Currently, there are two supported options: +- **`all`:** return all object fields in the response +- **`default`:** return all object fields **except**: + - `enabledRoutes` + - `advertisedRoutes` + - `clientConnectivity` (which contains the following fields: `mappingVariesByDestIP`, `derp`, `endpoints`, `latency`, and `clientSupports`) + +### Request example + +``` sh +curl "https://api.tailscale.com/api/v2/device/12345?fields=all" \ + -u "tskey-api-xxxxx:" +``` + +### Response + +``` jsonc +{ + "addresses":[ + "100.71.74.78", + "fd7a:115c:a1e0:ac82:4843:ca90:697d:c36e" + ], + "id":"12345", + + // Additional fields as documented in device "Attributes" section above +} +{ + "addresses":[ + "100.74.66.78", + "fd7a:115c:a1e0:ac82:4843:ca90:697d:c36f" + ], + "id":"67890", + + // Additional fields as documented in device "Attributes" section above +} +``` + + + +## Delete device + +``` http +DELETE /api/v2/device/{deviceID} +``` + +Deletes the supplied device from its tailnet. The device must belong to the user's tailnet. Deleting shared/external devices is not supported. -Supply the device of interest in the path using its ID. +### Parameters -##### Parameters -No parameters. +#### `deviceid` (required in URL path) -##### Example -``` -DELETE /api/v2/device/12345 +The ID of the device. + +### Request example + +``` sh curl -X DELETE 'https://api.tailscale.com/api/v2/device/12345' \ - -u "tskey-yourapikey123:" -v + -u "tskey-api-xxxxx:" ``` -Response +### Response If successful, the response should be empty: -``` -< HTTP/1.1 200 OK -... -* Connection #0 to host left intact -* Closing connection 0 + +``` http +HTTP/1.1 200 OK ``` If the device is not owned by your tailnet: -``` -< HTTP/1.1 501 Not Implemented + +``` http +HTTP/1.1 501 Not Implemented ... {"message":"cannot delete devices outside of your tailnet"} ``` + - +## Get device routes -#### `GET /api/v2/device/:deviceID/routes` - fetch subnet routes that are advertised and enabled for a device +``` http +GET /api/v2/device/{deviceID}/routes +``` -Retrieves the list of subnet routes that a device is advertising, as well as those that are enabled for it. Enabled routes are not necessarily advertised (e.g. for pre-enabling), and likewise, advertised routes are not necessarily enabled. +Retrieve the list of [subnet routes](#subnet-routes) that a device is advertising, as well as those that are enabled for it: +- **Enabled routes:** The subnet routes for this device that have been approved by the tailnet admin. +- **Advertised routes:** The subnets this device intends to expose. -##### Parameters +### Parameters -No parameters. +#### `deviceid` (required in URL path) -##### Example +The ID of the device. -``` -curl 'https://api.tailscale.com/api/v2/device/11055/routes' \ --u "tskey-yourapikey123:" -``` +### Request example -Response +``` sh +curl "https://api.tailscale.com/api/v2/device/11055/routes" \ +-u "tskey-api-xxxxx:" ``` + +### Response + +Returns the enabled and advertised subnet routes for a device. + +``` jsonc { "advertisedRoutes" : [ - "10.0.1.0/24", - "1.2.0.0/16", - "2.0.0.0/24" + "10.0.0.0/16", + "192.168.1.0/24" ], "enabledRoutes" : [] } ``` - + -#### `POST /api/v2/device/:deviceID/routes` - set the subnet routes that are enabled for a device +## Set device routes -Sets which subnet routes are enabled to be routed by a device by replacing the existing list of subnet routes with the supplied parameters. Routes can be enabled without a device advertising them (e.g. for preauth). Returns a list of enabled subnet routes and a list of advertised subnet routes for a device. +``` http +POST /api/v2/device/{deviceID}/routes +``` -##### Parameters +Sets a device's enabled [subnet routes](#subnet-routes) by replacing the existing list of subnet routes with the supplied parameters. +Advertised routes cannot be set through the API, since they must be set directly on the device. -###### POST Body -`routes` - The new list of enabled subnet routes in JSON. -``` +### Parameters + +#### `deviceid` (required in URL path) + +The ID of the device. + +#### `routes` (required in `POST` body) + +The new list of enabled subnet routes. + +``` jsonc { - "routes": ["10.0.1.0/24", "1.2.0.0/16", "2.0.0.0/24"] + "routes": ["10.0.0.0/16", "192.168.1.0/24"] } ``` -##### Example +### Request example -``` -curl 'https://api.tailscale.com/api/v2/device/11055/routes' \ --u "tskey-yourapikey123:" \ ---data-binary '{"routes": ["10.0.1.0/24", "1.2.0.0/16", "2.0.0.0/24"]}' +``` sh +curl "https://api.tailscale.com/api/v2/device/11055/routes" \ +-u "tskey-api-xxxxx:" \ +--data-binary '{"routes": ["10.0.0.0/16", "192.168.1.0/24"]}' ``` -Response +### Response -``` +Returns the enabled and advertised subnet routes for a device. + +``` jsonc { "advertisedRoutes" : [ - "10.0.1.0/24", - "1.2.0.0/16", - "2.0.0.0/24" + "10.0.0.0/16", + "192.168.1.0/24" ], "enabledRoutes" : [ - "10.0.1.0/24", - "1.2.0.0/16", - "2.0.0.0/24" + "10.0.0.0/16", + "192.168.1.0/24" ] } ``` - + + +## Authorize device -#### `POST /api/v2/device/:deviceID/authorized` - authorize a device +``` http +POST /api/v2/device/{deviceID}/authorized +``` -Marks a device as authorized, for Tailnets where device authorization is required. +Authorize a device. This call marks a device as authorized for tailnets where device authorization is required. -##### Parameters +This returns a successful 2xx response with an empty JSON object in the response body. -###### POST Body -`authorized` - whether the device is authorized; only `true` is currently supported. -``` +### Parameters + +#### `deviceid` (required in URL path) + +The ID of the device. + +#### `authorized` (required in `POST` body) + +Specify whether the device is authorized. Only 'true' is currently supported. + +``` jsonc { "authorized": true } ``` -##### Example +### Request example -``` -curl 'https://api.tailscale.com/api/v2/device/11055/authorized' \ --u "tskey-yourapikey123:" \ +``` sh +curl "https://api.tailscale.com/api/v2/device/11055/authorized" \ +-u "tskey-api-xxxxx:" \ --data-binary '{"authorized": true}' ``` -The response is 2xx on success. The response body is currently an empty JSON -object. +### Response - +The response is 2xx on success. The response body is currently an empty JSON object. -#### `POST /api/v2/device/:deviceID/tags` - update tags on a device + -Updates the tags set on a device. +## Update device tags -##### Parameters +``` http +POST /api/v2/device/{deviceID}/tags +``` -###### POST Body +Update the tags set on a device. +Tags let you assign an identity to a device that is separate from human users, and use that identity as part of an ACL to restrict access. +Tags are similar to role accounts, but more flexible. -`tags` - The new list of tags for the device. +Tags are created in the tailnet policy file by defining the tag and an owner of the tag. +Once a device is tagged, the tag is the owner of that device. +A single node can have multiple tags assigned. -``` +Consult the policy file for your tailnet in the [admin console](https://login.tailscale.com/admin/acls) for the list of tags that have been created for your tailnet. +Learn more about [tags](https://tailscale.com/kb/1068/). + +This returns a 2xx code if successful, with an empty JSON object in the response body. + +### Parameters + +#### `deviceid` (required in URL path) + +The ID of the device. + +#### `tags` (required in `POST` body) + +The new list of tags for the device. + +``` jsonc { "tags": ["tag:foo", "tag:bar"] } ``` -##### Example +### Request example -``` -curl 'https://api.tailscale.com/api/v2/device/11055/tags' \ --u "tskey-yourapikey123:" \ +``` sh +curl "https://api.tailscale.com/api/v2/device/11055/tags" \ +-u "tskey-api-xxxxx:" \ --data-binary '{"tags": ["tag:foo", "tag:bar"]}' ``` -The response is 2xx on success. The response body is currently an empty JSON -object. +### Response - +The response is 2xx on success. The response body is currently an empty JSON object. -#### `POST /api/v2/device/:deviceID/key` - update device key +If the tags supplied in the `POST` call do not exist in the tailnet policy file, the response is '400 Bad Request': -Allows for updating properties on the device key. +``` jsonc +{ + "message": "requested tags [tag:madeup tag:wrongexample] are invalid or not permitted" +} +``` -##### Parameters + -###### POST Body +## Update device key -`keyExpiryDisabled` +``` http +POST /api/v2/device/{deviceID}/key +``` -- Provide `true` to disable the device's key expiry. The original key expiry time is still maintained. Upon re-enabling, the key will expire at that original time. -- Provide `false` to enable the device's key expiry. Sets the key to expire at the original expiry time prior to disabling. The key may already have expired. In that case, the device must be re-authenticated. -- Empty value will not change the key expiry. +Update properties of the device key. -``` +### Parameters + +#### `deviceid` (required in URL path) + +The ID of the device. + +#### `keyExpiryDisabled` (optional in `POST` body) + +Disable or enable the expiry of the device's node key. + +When a device is added to a tailnet, its key expiry is set according to the tailnet's [key expiry](https://tailscale.com/kb/1028/) setting. +If the key is not refreshed and expires, the device can no longer communicate with other devices in the tailnet. + +Set `"keyExpiryDisabled": true` to disable key expiry for the device and allow it to rejoin the tailnet (for example to access an accidentally expired device). +You can then call this method again with `"keyExpiryDisabled": false` to re-enable expiry. + +``` jsonc { "keyExpiryDisabled": true } ``` -##### Example +- If `true`, disable the device's key expiry. + The original key expiry time is still maintained. + Upon re-enabling, the key will expire at that original time. +- If `false`, enable the device's key expiry. + Sets the key to expire at the original expiry time prior to disabling. + The key may already have expired. In that case, the device must be re-authenticated. +- Empty value will not change the key expiry. + +This returns a 2xx code on success, with an empty JSON object in the response body. -``` -curl 'https://api.tailscale.com/api/v2/device/11055/key' \ --u "tskey-yourapikey123:" \ +### Request example + +``` sh +curl "https://api.tailscale.com/api/v2/device/11055/key" \ +-u "tskey-api-xxxxx:" \ --data-binary '{"keyExpiryDisabled": true}' ``` -The response is 2xx on success. The response body is currently an empty JSON -object. +### Response -## Tailnet +The response is 2xx on success. The response body is currently an empty JSON object. -A tailnet is your private network, composed of all the devices on it and their configuration. For more information on tailnets, see [our user-facing documentation](https://tailscale.com/kb/1136/tailnet/). +# Tailnet -When making API requests, your tailnet is identified by the organization name. You can find it on the [Settings page](https://login.tailscale.com/admin/settings) of the admin console. +A tailnet is your private network, composed of all the devices on it and their configuration. +Learn more about [tailnets](https://tailscale.com/kb/1136/). -For example, if `alice@example.com` belongs to the `example.com` tailnet, they would use the following format for API calls: +When specifying a tailnet in the API, you can: -``` -GET /api/v2/tailnet/example.com/... -curl https://api.tailscale.com/api/v2/tailnet/example.com/... -``` +- Provide a dash (`-`) to reference the default tailnet of the access token being used to make the API call. + This is the best option for most users. + Your API calls would start: + ``` sh + curl "https://api.tailscale.com/api/v2/tailnet/-/..." + ``` -For solo plans, the tailnet is the email you signed up with. -So `alice@gmail.com` has the tailnet `alice@gmail.com` since `@gmail.com` is a shared email host. -Her API calls would have the following format: -``` -GET /api/v2/tailnet/alice@gmail.com/... -curl https://api.tailscale.com/api/v2/tailnet/alice@gmail.com/... -``` +- Provide the **organization** name found on the **[General Settings](https://login.tailscale.com/admin/settings/general)** + page of the Tailscale admin console (not to be confused with the "tailnet name" found in the DNS tab). -Alternatively, you can specify the value "-" to refer to the default tailnet of -the authenticated user making the API call. For example: -``` -GET /api/v2/tailnet/-/... -curl https://api.tailscale.com/api/v2/tailnet/-/... + For example, if your organization name is `alice@gmail.com`, your API calls would start: + + ``` sh + curl "https://api.tailscale.com/api/v2/tailnet/alice@gmail.com/..." + ``` + +## Policy File + +The tailnet policy file contains access control lists and related configuration. +The policy file is expressed using "[HuJSON](https://github.com/tailscale/hujson#readme)" +(human JSON, a superset of JSON that allows comments and trailing commas). +Most policy file API methods can also return regular JSON for compatibility with other tools. +Learn more about [network access controls](https://tailscale.com/kb/1018/). + + + +## Get Policy File + +``` http +GET /api/v2/tailnet/{tailnet}/acl ``` -Tailnets are a top-level resource. ACL is an example of a resource that is tied to a top-level tailnet. +Retrieves the current policy file for the given tailnet; this includes the ACL along with the rules and tests that have been defined. -### ACL +This method can return the policy file as JSON or HuJSON, depending on the `Accept` header. +The response also includes an `ETag` header, which can be optionally included when [updating the policy file](#update-policy-file) to avoid missed updates. - +### Parameters -#### `GET /api/v2/tailnet/:tailnet/acl` - fetch ACL for a tailnet +#### `tailnet` (required in URL path) -Retrieves the ACL that is currently set for the given tailnet. Supply the tailnet of interest in the path. This endpoint can send back either the HuJSON of the ACL or a parsed JSON, depending on the `Accept` header. +The tailnet organization name. -##### Parameters +#### `Accept` (optional in request header) -###### Headers -`Accept` - Response is parsed `JSON` if `application/json` is explicitly named, otherwise HuJSON will be returned. +Response is encoded as JSON if `application/json` is requested, otherwise HuJSON will be returned. -##### Returns -Returns the ACL HuJSON by default. Returns a parsed JSON of the ACL (sans comments) if the `Accept` type is explicitly set to `application/json`. An `ETag` header is also sent in the response, which can be optionally used in POST requests to avoid missed updates. - +#### `details` (optional in query string) -##### Example +Request a detailed description of the tailnet policy file by providing `details=1` in the URL query string. +If using this, do not supply an `Accept` parameter in the header. -###### Requesting a HuJSON response: -``` -GET /api/v2/tailnet/example.com/acl -curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl' \ - -u "tskey-yourapikey123:" \ - -H "Accept: application/hujson" \ - -v -``` +The response will contain a JSON object with the fields: +- **tailnet policy file:** a base64-encoded string representation of the huJSON format +- **warnings:** array of strings for syntactically valid but nonsensical entries +- **errors:** an array of strings for parsing failures + +### Request example (response in HuJSON format) -Response +``` sh +curl "https://api.tailscale.com/api/v2/tailnet/example.com/acl" \ + -u "tskey-api-xxxxx:" \ ``` + +### Response in HuJSON format + +On success, returns a 200 status code and the tailnet policy file in HuJSON format. +No errors or warnings are returned. + +``` jsonc ... Content-Type: application/hujson Etag: "e0b2816b418b3f266309d94426ac7668ab3c1fa87798785bf82f1085cc2f6d9c" @@ -402,46 +731,40 @@ Etag: "e0b2816b418b3f266309d94426ac7668ab3c1fa87798785bf82f1085cc2f6d9c" // Example/default ACLs for unrestricted connections. { - "tests": [], - // Declare static groups of users beyond those in the identity service. - "groups": { - "group:example": [ - "user1@example.com", - "user2@example.com" - ], - }, - // Declare convenient hostname aliases to use in place of IP addresses. - "hosts": { - "example-host-1": "100.100.100.100", - }, - // Access control lists. - "acls": [ - // Match absolutely everything. Comment out this section if you want - // to define specific ACL restrictions. - { - "Action": "accept", - "Users": [ - "*" - ], - "Ports": [ - "*:*" - ] - }, - ] + // Declare static groups of users beyond those in the identity service. + "groups": { + "group:example": ["user1@example.com", "user2@example.com"], + }, + + // Declare convenient hostname aliases to use in place of IP addresses. + "hosts": { + "example-host-1": "100.100.100.100", + }, + + // Access control lists. + "acls": [ + // Match absolutely everything. + // Comment this section out if you want to define specific restrictions. + {"action": "accept", "src": ["*"], "dst": ["*:*"]}, + ], } -``` -###### Requesting a JSON response: ``` -GET /api/v2/tailnet/example.com/acl -curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl' \ - -u "tskey-yourapikey123:" \ + +### Request example (response in JSON format) + +``` sh +curl "https://api.tailscale.com/api/v2/tailnet/example.com/acl" \ + -u "tskey-api-xxxxx:" \ -H "Accept: application/json" \ - -v ``` -Response -``` +### Response in JSON format + +On success, returns a 200 status code and the tailnet policy file in JSON format. +No errors or warnings are returned. + +``` jsonc ... Content-Type: application/json Etag: "e0b2816b418b3f266309d94426ac7668ab3c1fa87798785bf82f1085cc2f6d9c" @@ -470,46 +793,82 @@ Etag: "e0b2816b418b3f266309d94426ac7668ab3c1fa87798785bf82f1085cc2f6d9c" } ``` - +### Request example (with details) + +``` sh +curl "https://api.tailscale.com/api/v2/tailnet/example.com/acl?details=1" \ + -u "tskey-api-xxxxx:" \ +``` + +### Response (with details) + +On success, returns a 200 status code and the tailnet policy file in a base64-encoded string representation of the huJSON format. +In addition, errors and warnings are returned. + +``` sh +{ + "acl": "Ly8gUG9raW5nIGFyb3VuZCBpbiB0aGUgQVBJIGRvY3MsIGhvcGluZyB5b3UnZCBmaW5kIHNvbWV0aGluZyBnb29kLCBlaD8KLy8gV2UgbGlrZSB5b3VyIHN0eWxlISAgR28gZ3JhYiB5b3Vyc2VsZiBhIFRhaWxzY2FsZSB0LXNoaXJ0IGlmIHRoZXJlIGFyZQovLyBzdGlsbCBzb21lIGF2YWlsYWJsZS4gQnV0IHNoaGguLi4gZG9uJ3QgdGVsbCBhbnlvbmUhCi8vCi8vICAgICAgICAgICAgIGh0dHBzOi8vc3dhZy5jb20vZ2lmdC82a29mNGs1Z3B1ZW95ZDB2NXd6MHJkYmMKewoJLy8gRGVjbGFyZSBzdGF0aWMgZ3JvdXBzIG9mIHVzZXJzIGJleW9uZCB0aG9zZSBpbiB0aGUgaWRlbnRpdHkgc2VydmljZS4KCSJncm91cHMiOiB7CgkJImdyb3VwOmV4YW1wbGUiOiBbInVzZXIxQGV4YW1wbGUuY29tIiwgInVzZXIyQGV4YW1wbGUuY29tIl0sCgl9LAoKCS8vIERlY2xhcmUgY29udmVuaWVudCBob3N0bmFtZSBhbGlhc2VzIHRvIHVzZSBpbiBwbGFjZSBvZiBJUCBhZGRyZXNzZXMuCgkiaG9zdHMiOiB7CgkJImV4YW1wbGUtaG9zdC0xIjogIjEwMC4xMDAuMTAwLjEwMCIsCgl9LAoKCS8vIEFjY2VzcyBjb250cm9sIGxpc3RzLgoJImFjbHMiOiBbCgkJLy8gTWF0Y2ggYWJzb2x1dGVseSBldmVyeXRoaW5nLgoJCS8vIENvbW1lbnQgdGhpcyBzZWN0aW9uIG91dCBpZiB5b3Ugd2FudCB0byBkZWZpbmUgc3BlY2lmaWMgcmVzdHJpY3Rpb25zLgoJCXsiYWN0aW9uIjogImFjY2VwdCIsICJ1c2VycyI6IFsiKiJdLCAicG9ydHMiOiBbIio6KiJdfSwKCV0sCn0K", + "warnings": [ + "\"group:example\": user not found: \"user1@example.com\"", + "\"group:example\": user not found: \"user2@example.com\"" + ], + "errors": null +} +``` + + + +## Update policy file -#### `POST /api/v2/tailnet/:tailnet/acl` - set ACL for a tailnet +``` http +POST /api/v2/tailnet/{tailnet}/acl` +``` -Sets the ACL for the given domain. +Sets the ACL for the given tailnet. HuJSON and JSON are both accepted inputs. An `If-Match` header can be set to avoid missed updates. -Returns the updated ACL in JSON or HuJSON according to the `Accept` header on success. Otherwise, errors are returned for incorrectly defined ACLs, ACLs with failing tests on attempted updates, and mismatched `If-Match` header and ETag. +On success, returns the updated ACL in JSON or HuJSON according to the `Accept` header. +Otherwise, errors are returned for incorrectly defined ACLs, ACLs with failing tests on attempted updates, and mismatched `If-Match` header and ETag. -##### Parameters +### Parameters -###### Headers -`If-Match` - A request header. Set this value to the ETag header provided in an `ACL GET` request to avoid missed updates. +#### tailnet (required in URL path) -A special value `ts-default` will ensure that ACL will be set only if current ACL is the default one (created automatically for each tailnet). +The tailnet organization name. -`Accept` - Sets the return type of the updated ACL. Response is parsed `JSON` if `application/json` is explicitly named, otherwise HuJSON will be returned. +#### `If-Match` (optional in request header) -###### POST Body +This is a safety mechanism to avoid overwriting other users' updates to the tailnet policy file. -The POST body should be a JSON or [HuJSON](https://github.com/tailscale/hujson#hujson---human-json) formatted JSON object. -An ACL policy may contain the following top-level properties: +- Set the `If-Match` value to that of the ETag header returned in a `GET` request to `/api/v2/tailnet/{tailnet}/acl`. + Tailscale compares the ETag value in your request to that of the current tailnet file and only replaces the file if there's a match. + (A mismatch indicates that another update has been made to the file.) + For example: `-H "If-Match: \"e0b2816b418\""` +- Alternately, set the `If-Match` value to `ts-default` to ensure that the policy file is replaced + _only if the current policy file is still the untouched default_ created automatically for each tailnet. + For example: `-H "If-Match: \"ts-default\""` -* `groups` - Static groups of users which can be used for ACL rules. -* `hosts` - Hostname aliases to use in place of IP addresses or subnets. -* `acls` - Access control lists. -* `tagOwners` - Defines who is allowed to use which tags. -* `tests` - Run on ACL updates to check correct functionality of defined ACLs. -* `autoApprovers` - Defines which users can advertise routes or exit nodes without further approval. -* `ssh` - Configures access policy for Tailscale SSH. -* `nodeAttrs` - Defines which devices can use certain features. +#### `Accept` (optional in request header) -See https://tailscale.com/kb/1018/acls for more information on those properties. +Sets the return type of the updated tailnet policy file. +Response is encoded as JSON if `application/json` is requested, otherwise HuJSON will be returned. -##### Example -``` +#### Tailnet policy file entries (required in `POST` body) + +Define the policy file in the `POST` body. +Include the entire policy file. +Note that the supplied object fully replaces your existing tailnet policy file. + +The `POST` body should be formatted as JSON or HuJSON. +Learn about the [ACL policy properties you can include in the request](https://tailscale.com/kb/1018/#tailscale-policy-syntax). + +### Request example + +``` sh POST /api/v2/tailnet/example.com/acl -curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl' \ - -u "tskey-yourapikey123:" \ +curl "https://api.tailscale.com/api/v2/tailnet/example.com/acl" \ + -u "tskey-api-xxxxx:" \ -H "If-Match: \"e0b2816b418b3f266309d94426ac7668ab3c1fa87798785bf82f1085cc2f6d9c\"" --data-binary '// Example/default ACLs for unrestricted connections. { @@ -534,8 +893,11 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl' \ }' ``` -Response: -``` +### Response + +A successful response returns an HTTP status of '200' and the modified tailnet policy file in JSON or HuJSON format, depending on the request header. + +``` jsonc // Example/default ACLs for unrestricted connections. { // Declare tests to check functionality of ACL rules. User must be a valid user with registered machines. @@ -559,7 +921,8 @@ Response: } ``` -Failed test error response: +### Response: failed test error + ``` { "message": "test(s) failed", @@ -574,26 +937,49 @@ Failed test error response: } ``` - + + +## Preview policy file rule matches -#### `POST /api/v2/tailnet/:tailnet/acl/preview` - preview rule matches on an ACL for a resource +``` http +POST /api/v2/tailnet/{tailnet}/acl/preview +``` +When given a user or IP port to match against, returns the tailnet policy rules that +apply to that resource without saving the policy file to the server. -Determines what rules match for a user on an ACL without saving the ACL to the server. +### Parameters -##### Parameters +#### `tailnet` (required in URL path) -###### Query Parameters -`type` - can be 'user' or 'ipport' -`previewFor` - if type=user, a user's email. If type=ipport, a IP address + port like "10.0.0.1:80". -The provided ACL is queried with this parameter to determine which rules match. +The tailnet organization name. -###### POST Body -ACL JSON or HuJSON (see https://tailscale.com/kb/1018/acls) +#### `type` (required in query string) -##### Example -``` -curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl/preview?previewFor=user1@example.com&type=user' \ - -u "tskey-yourapikey123:" \ +Specify for which type of resource (user or IP port) matching rules are to be fetched. +Read about [previewing changes in the admin console](https://tailscale.com/kb/1018/#previewing-changes). + +- `user`: Specify `user` if the `previewFor` value is a user's email. + Note that `user` remains in the API for compatibility purposes, but has been replaced by `src` in policy files. +- `ipport`: Specify `ipport` if the `previewFor` value is an IP address and port. + Note that `ipport` remains in the API for compatibility purposes, but has been replaced by `dst` in policy files. + +#### `previewFor` (required in query string) + +- If `type=user`, provide the email of a valid user with registered machines. +- If `type=ipport`, provide an IP address + port: `10.0.0.1:80`. + +The supplied policy file is queried with this parameter to determine which rules match. + +#### Tailnet policy file (required in `POST` body) + +Provide the tailnet policy file in the `POST` body in JSON or HuJSON format. +Learn about [tailnet policy file entries](https://tailscale.com/kb/1018). + +### Request example + +``` sh +curl "https://api.tailscale.com/api/v2/tailnet/example.com/acl/preview?previewFor=user1@example.com&type=user" \ + -u "tskey-api-xxxxx:" \ --data-binary '// Example/default ACLs for unrestricted connections. { // Declare tests to check functionality of ACL rules. User must be a valid user with registered machines. @@ -617,46 +1003,86 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl/preview?previewFo }' ``` -Response: +### Response + +A successful response returns an HTTP status of '200' and a list of rules that apply to the resource supplied as a list of matches as JSON objects. +Each match object includes: +- `users`: array of strings indicating source entities affected by the rule +- `ports`: array of strings representing destinations that can be accessed +- `lineNumber`: integer indicating the rule's location in the policy file + +The response also echoes the `type` and `previewFor` values supplied in the request. + +``` jsonc +{ + "matches": [ + { + "users": ["*"], + "ports": ["*:*"], + "lineNumber": 19 + } + ], + "type": "user", + "previewFor: "user1@example.com" +} ``` -{"matches":[{"users":["*"],"ports":["*:*"],"lineNumber":19}],"user":"user1@example.com"} + + + +## Validate and test policy file + +``` http +POST /api/v2/tailnet/{tailnet}/acl/validate ``` - +This method works in one of two modes, neither of which modifies your current tailnet policy file: -#### `POST /api/v2/tailnet/:tailnet/acl/validate` - run validation tests against the tailnet's active ACL +- **Run ACL tests:** When the **request body contains ACL tests as a JSON array**, + Tailscale runs ACL tests against the tailnet's current policy file. + Learn more about [ACL tests](https://tailscale.com/kb/1018/#tests). +- **Validate a new policy file:** When the **request body is a JSON object**, + Tailscale interprets the body as a hypothetical new tailnet policy file with new ACLs, including any new rules and tests. + It validates that the policy file is parsable and runs tests to validate the existing rules. -This endpoint works in one of two modes: +In either case, this method does not modify the tailnet policy file in any way. -1. with a request body that's a JSON array, the body is interpreted as ACL tests to run against the domain's current ACLs. -2. with a request body that's a JSON object, the body is interpreted as a hypothetical new JSON (HuJSON) body with new ACLs, including any tests. +### Parameters for "Run ACL tests" mode -In either case, this endpoint does not modify the ACL in any way. +#### `tailnet` (required in URL path) -##### Parameters +The tailnet organization name. -###### POST Body +#### ACL tests (required in `POST` body) -The POST body should be a JSON formatted array of ACL Tests. +The `POST` body should be a JSON formatted array of ACL Tests. +Learn more about [tailnet policy file tests](https://tailscale.com/kb/1018/#tests). -See https://tailscale.com/kb/1018/acls for more information on the format of ACL tests. +### Request example to run ACL tests -##### Example with tests -``` -POST /api/v2/tailnet/example.com/acl/validate -curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl/validate' \ - -u "tskey-yourapikey123:" \ +``` sh +curl "https://api.tailscale.com/api/v2/tailnet/example.com/acl/validate" \ + -u "tskey-api-xxxxx:" \ --data-binary ' [ {"src": "user1@example.com", "accept": ["example-host-1:22"], "deny": ["example-host-2:100"]} ]' ``` -##### Example with an ACL body -``` -POST /api/v2/tailnet/example.com/acl/validate -curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl/validate' \ - -u "tskey-yourapikey123:" \ +### Parameters for "Validate a new policy file" mode + +#### `tailnet` (required in URL path) + +The tailnet organization name. + +#### Entire tailnet policy file (required in `POST` body) + +The `POST` body should be a JSON object with a JSON or HuJSON representation of a tailnet policy file. + +### Request example to validate a policy file + +``` sh +curl "https://api.tailscale.com/api/v2/tailnet/example.com/acl/validate" \ + -u "tskey-api-xxxxx:" \ --data-binary ' { "acls": [ @@ -668,167 +1094,225 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl/validate' \ }' ``` -Response: +### Response -The HTTP status code will be 200 if the request was well formed and there were no server errors, even in the case of failing tests or an invalid ACL. Look at the response body to determine whether there was a problem within your ACL or tests. +The HTTP status code will be '200' if the request was well formed and there were no server errors, even in the case of failing tests or an invalid ACL. +Look at the response body to determine whether there was a problem within your ACL or tests: +- If the tests are valid, an empty body or a JSON object with no `message` is returned. +- If there's a problem, the response body will be a JSON object with a non-empty `message` property and optionally additional details in `data`: -If there's a problem, the response body will be a JSON object with a non-empty `message` property and optionally additional details in `data`: + ``` jsonc + { + "message":"test(s) failed", + "data":[ + { + "user":"user1@example.com", + "errors":["address \"2.2.2.2:22\": want: Drop, got: Accept"] + } + ] + } + ``` -``` -{ - "message":"test(s) failed", - "data":[ - { - "user":"user1@example.com", - "errors":["address \"2.2.2.2:22\": want: Drop, got: Accept"] - } - ] -} -``` + -An empty body or a JSON object with no `message` is returned on success. +## List tailnet devices - +``` http +GET /api/v2/tailnet/{tailnet}/devices +``` -### Devices +Lists the devices in a tailnet. +Optionally use the `fields` query parameter to explicitly indicate which fields are returned. - +### Parameters -#### `GET /api/v2/tailnet/:tailnet/devices` - list the devices for a tailnet -Lists the devices in a tailnet. -Supply the tailnet of interest in the path. -Use the `fields` query parameter to explicitly indicate which fields are returned. +#### `tailnet` (required in URL path) +The tailnet organization name. -##### Parameters +#### `fields` (optional in query string) -###### Query Parameters -`fields` - Controls which fields will be included in the returned response. -Currently, supported options are: -* `all`: Returns all fields in the response. -* `default`: return all fields except: +Controls whether the response returns **all** fields or only a predefined subset of fields. +Currently, there are two supported options: +- **`all`:** return all fields in the response +- **`default`:** return all fields **except**: * `enabledRoutes` * `advertisedRoutes` * `clientConnectivity` (which contains the following fields: `mappingVariesByDestIP`, `derp`, `endpoints`, `latency`, and `clientSupports`) -Use commas to separate multiple options. -If more than one option is indicated, then the union is used. -For example, for `fields=default,all`, all fields are returned. -If the `fields` parameter is not provided, then the default option is used. +If the `fields` parameter is not supplied, then the default (limited fields) option is used. -##### Example +### Request example for default set of fields -``` -GET /api/v2/tailnet/example.com/devices -curl 'https://api.tailscale.com/api/v2/tailnet/example.com/devices' \ - -u "tskey-yourapikey123:" +``` sh +curl "https://api.tailscale.com/api/v2/tailnet/example.com/devices" \ + -u "tskey-api-xxxxx:" ``` -Response -``` -{ - "devices":[ - { - "addresses":[ - "100.68.203.125" - ], - "clientVersion":"date.20201107", - "os":"macOS", - "name":"user1-device.example.com", - "created":"2020-11-30T22:20:04Z", - "lastSeen":"2020-11-30T17:20:04-05:00", - "hostname":"User1-Device", - "machineKey":"mkey:user1-node-key", - "nodeKey":"nodekey:user1-node-key", - "id":"12345", - "user":"user1@example.com", - "expires":"2021-05-29T22:20:04Z", - "keyExpiryDisabled":false, - "authorized":false, - "isExternal":false, - "updateAvailable":false, - "blocksIncomingConnections":false, - }, - { - "addresses":[ - "100.111.63.90" - ], - "clientVersion":"date.20201107", - "os":"macOS", - "name":"user2-device.example.com", - "created":"2020-11-30T22:21:03Z", - "lastSeen":"2020-11-30T17:21:03-05:00", - "hostname":"User2-Device", - "machineKey":"mkey:user2-machine-key", - "nodeKey":"nodekey:user2-node-key", - "id":"48810", - "user":"user2@example.com", - "expires":"2021-05-29T22:21:03Z", - "keyExpiryDisabled":false, - "authorized":false, - "isExternal":false, - "updateAvailable":false, - "blocksIncomingConnections":false, - } - ] -} +### Request example for all fields + +``` sh +curl "https://api.tailscale.com/api/v2/tailnet/example.com/devices?fields=all" \ + -u "tskey-api-xxxxx:" ``` - +### Response -### Keys +On success, returns a 200 status code and a JSON array of the tailnet devices and their details. - +## Tailnet keys -#### `GET /api/v2/tailnet/:tailnet/keys` - list the keys for a tailnet +These methods operate primarily on auth keys, and in some cases on [API access tokens](#authentication). -Returns a list of active keys for a tailnet -for the user who owns the API key used to perform this query. -Supply the tailnet of interest in the path. +- Auth keys: Pre-authentication keys (or "auth keys") let you register new devices on a tailnet without needing to sign in via a web browser. + Auth keys are identifiable by the prefix `tskey-auth-`. Learn more about [auth keys](https://tailscale.com/kb/1085/). -##### Parameters -No parameters. +- API access tokens: used to [authenticate API requests](#authentication). -##### Returns +If you authenticate with a user-owned API access token, all the methods on tailnet keys operate on _keys owned by that user_. +If you authenticate with an access token derived from an OAuth client, then these methods operate on _keys owned by the tailnet_. +Learn more about [OAuth clients](https://tailscale.com/kb/1215). -Returns a JSON object with the IDs of all active keys. -This includes both API keys and also machine authentication keys. -In the future, this may provide more information about each key than just the ID. +The `POST /api/v2/tailnet/{tailnet}/keys` method is used to create auth keys only. +The remaining three methods operate on auth keys and API access tokens. -##### Example +### Attributes +``` jsonc +{ + // capabilities (JSON object) is a mapping of resources to permissible + // actions. + "capabilities": { + + // devices (JSON object) specifies the key's permissions over devices. + "devices": { + + // create (JSON object) specifies the key's permissions when + // creating devices. + "create": { + + // reusable (boolean) for auth keys only; reusable auth keys + // can be used multiple times to register different devices. + // Learn more about reusable auth keys at + // https://tailscale.com/kb/1085/#types-of-auth-keys + "reusable": false, + + // ephemeral (boolean) for auth keys only; ephemeral keys are + // used to connect and then clean up short-lived devices. + // Learn about ephemeral nodes at https://tailscale.com/kb/1111/. + "ephemeral": false, + + // preauthorized (boolean) for auth keys only; these are also + // referred to as "pre-approved" keys. 'true' means that devices + // registered with this key won't require additional approval from a + // tailnet admin. + // Learn about device approval at https://tailscale.com/kb/1099/. + "preauthorized": false, + + // tags (string) are the tags that will be set on devices registered + // with this key. + // Learn about tags at https://tailscale.com/kb/1068/. + "tags": [ + "tag:example" + ] + } + } + } + + // expirySeconds (int) is the duration in seconds a new key is valid. + "expirySeconds": 86400 +} ``` -curl 'https://api.tailscale.com/api/v2/tailnet/example.com/keys' \ - -u "tskey-yourapikey123:" + + + +## List tailnet keys + +``` http +GET /api/v2/tailnet/{tailnet}/keys ``` -Response: +Returns a list of active auth keys and API access tokens. The set of keys returned depends on the access token used to make the request: +- If the API call is made with a user-owned API access token, this returns only the keys owned by that user. +- If the API call is made with an access token derived from an OAuth client, this returns all keys owned directly by the tailnet. + +### Parameters + +#### `tailnet` (required in URL path) + +The tailnet organization name. + +### Request example + +``` sh +curl "https://api.tailscale.com/api/v2/tailnet/example.com/keys" \ + -u "tskey-api-xxxxx:" ``` + +### Response + +Returns a JSON object with the IDs of all active keys. + +``` jsonc {"keys": [ - {"id": "kYKVU14CNTRL"}, - {"id": "k68VdZ3CNTRL"}, - {"id": "kJ9nq43CNTRL"}, - {"id": "kkThgj1CNTRL"} + {"id": "XXXX14CNTRL"}, + {"id": "XXXXZ3CNTRL"}, + {"id": "XXXX43CNTRL"}, + {"id": "XXXXgj1CNTRL"} ]} ``` - + -#### `POST /api/v2/tailnet/:tailnet/keys` - create a new key for a tailnet +## Create auth key -Create a new key in a tailnet associated -with the user who owns the API key used to perform this request. -Supply the tailnet in the path. +``` http +POST /api/v2/tailnet/{tailnet}/keys +``` -##### Parameters +Creates a new auth key in the specified tailnet. +The key will be associated with the user who owns the API access token used to make this call, +or, if the call is made with an access token derived from an OAuth client, the key will be owned by the tailnet. -###### POST Body -`capabilities` - A mapping of resources to permissible actions. +Returns a JSON object with the supplied capabilities in addition to the generated key. +The key should be recorded and kept safe and secure because it wields the capabilities specified in the request. +The identity of the key is embedded in the key itself and can be used to perform operations on the key (e.g., revoking it or retrieving information about it). +The full key can no longer be retrieved after the initial response. -`expirySeconds` - (Optional) How long the key is valid for in seconds. - Defaults to 90d. +### Parameters -``` +#### `tailnet` (required in URL path) + +The tailnet organization name. + +#### Tailnet key object (required in `POST` body) + +Supply the tailnet key attributes as a JSON object in the `POST` body following the request example below. + +At minimum, the request `POST` body must have a `capabilities` object (see below). +With nothing else supplied, such a request generates a single-use key with no tags. + +Note the following about required vs. optional values: + +- **`capabilities`:** A `capabilities` object is required and must contain `devices`. + +- **`devices`:** A `devices` object is required within `capabilities`, but can be an empty JSON object. + +- **`tags`:** Whether tags are required or optional depends on the owner of the auth key: + - When creating an auth key _owned by the tailnet_ (using OAuth), it must have tags. + The auth tags specified for that new auth key must exactly match the tags that are on the OAuth client used to create that auth key (or they must be tags that are owned by the tags that are on the OAuth client used to create the auth key). + - When creating an auth key _owned by a user_ (using a user's access token), tags are optional. + +- **`expirySeconds`:** Optional in `POST` body. + Specifies the duration in seconds until the key should expire. + Defaults to 90 days if not supplied. + +### Request example + +``` jsonc +curl "https://api.tailscale.com/api/v2/tailnet/example.com/keys" \ + -u "tskey-api-xxxxx:" \ + --data-binary ' { "capabilities": { "devices": { @@ -836,29 +1320,27 @@ Supply the tailnet in the path. "reusable": false, "ephemeral": false, "preauthorized": false, - "tags": [ - "tag:example" - ] + "tags": [ "tag:example" ] } } }, - "expirySeconds": 1440 -} + "expirySeconds": 86400 +}' ``` -##### Returns - -Returns a JSON object with the provided capabilities in addition to the -generated key. The key should be recorded and kept safe and secure as it -wields the capabilities specified in the request. The identity of the key -is embedded in the key itself and can be used to perform operations on -the key (e.g., revoking it or retrieving information about it). -The full key can no longer be retrieved by the server. +### Response -##### Example +The response is a JSON object that includes the `key` value, which will only be returned once. +Record and safely store the `key` returned. +It holds the capabilities specified in the request and can no longer be retrieved by the server. -``` -echo '{ +``` jsonc +{ + "id": "k123456CNTRL", + "key": "tskey-auth-k123456CNTRL-abcdefghijklmnopqrstuvwxyz", + "created": "2021-12-09T23:22:39Z", + "expires": "2022-03-09T23:22:39Z", + "revoked": "2022-03-12T23:22:39Z", "capabilities": { "devices": { "create": { @@ -869,49 +1351,43 @@ echo '{ } } } -}' | curl -X POST --data-binary @- https://api.tailscale.com/api/v2/tailnet/example.com/keys \ - -u "tskey-yourapikey123:" \ - -H "Content-Type: application/json" | jsonfmt +} ``` -Response: -``` -{ - "id": "k123456CNTRL", - "key": "tskey-k123456CNTRL-abcdefghijklmnopqrstuvwxyz", - "created": "2021-12-09T23:22:39Z", - "expires": "2022-03-09T23:22:39Z", - "capabilities": {"devices": {"create": {"reusable": false, "ephemeral": false, "preauthorized": false, "tags": [ "tag:example" ]}}} -} + + +## Get key + +``` http +GET /api/v2/tailnet/{tailnet}/keys/{keyid} ``` - +Returns a JSON object with information about a specific key, such as its creation and expiration dates and its capabilities. -#### `GET /api/v2/tailnet/:tailnet/keys/:keyid` - get information for a specific key +### Parameters -Returns a JSON object with information about specific key. -Supply the tailnet and key ID of interest in the path. +#### `tailnet` (required in URL path) -##### Parameters -No parameters. +The tailnet organization name. -##### Returns +#### `keyId` (required in URL path) -Returns a JSON object with information about the key such as -when it was created and when it expires. -It also lists the capabilities associated with the key. +The ID of the key. -##### Example +### Request example -``` -curl 'https://api.tailscale.com/api/v2/tailnet/example.com/keys/k123456CNTRL' \ - -u "tskey-yourapikey123:" +``` sh +curl "https://api.tailscale.com/api/v2/tailnet/example.com/keys/k123456CNTRL" \ + -u "tskey-api-xxxxx:" ``` -Response: -``` +### Response + +The response is a JSON object with information about the key supplied. + +``` jsonc { - "id": "k123456CNTRL", + "id": "abc123456CNTRL", "created": "2022-05-05T18:55:44Z", "expires": "2022-08-03T18:55:44Z", "capabilities": { @@ -930,226 +1406,307 @@ Response: } ``` - + -#### `DELETE /api/v2/tailnet/:tailnet/keys/:keyid` - delete a specific key +## Delete key + +``` http +DELETE /api/v2/tailnet/{tailnet}/keys/{keyid} +``` Deletes a specific key. -Supply the tailnet and key ID of interest in the path. -##### Parameters -No parameters. +### Parameters -##### Returns -This reports status 200 upon success. +#### `tailnet` (required in URL path) -##### Example +The tailnet organization name. -``` +#### `keyId` (required in URL path) + +The ID of the key. The key ID can be found in the [admin console](https://login.tailscale.com/admin/settings/keys). + +### Request example + +``` sh curl -X DELETE 'https://api.tailscale.com/api/v2/tailnet/example.com/keys/k123456CNTRL' \ - -u "tskey-yourapikey123:" + -u "tskey-api-xxxxx:" ``` - +### Response -### DNS +This returns status 200 upon success. - + -#### `GET /api/v2/tailnet/:tailnet/dns/nameservers` - list the DNS nameservers for a tailnet -Lists the DNS nameservers for a tailnet. -Supply the tailnet of interest in the path. +## DNS -##### Parameters -No parameters. +The tailnet DNS methods are provided for fetching and modifying various DNS settings for a tailnet. +These include nameservers, DNS preferences, and search paths. +Learn more about [DNS in Tailscale](https://tailscale.com/kb/1054/). -##### Example + +## Get nameservers + +``` http +GET /api/v2/tailnet/{tailnet}/dns/nameservers ``` -GET /api/v2/tailnet/example.com/dns/nameservers -curl 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/nameservers' \ - -u "tskey-yourapikey123:" -``` -Response +Lists the global DNS nameservers for a tailnet. + +### Parameters + +#### `tailnet` (required in URL path) + +The tailnet organization name. + +### Request example + +``` sh +curl "https://api.tailscale.com/api/v2/tailnet/example.com/dns/nameservers" \ + -u "tskey-api-xxxxx:" ``` + +### Response + +``` jsonc { "dns": ["8.8.8.8"], } ``` - + -#### `POST /api/v2/tailnet/:tailnet/dns/nameservers` - replaces the list of DNS nameservers for a tailnet -Replaces the list of DNS nameservers for the given tailnet with the list supplied by the user. -Supply the tailnet of interest in the path. -Note that changing the list of DNS nameservers may also affect the status of MagicDNS (if MagicDNS is on). +## Set nameservers -##### Parameters -###### POST Body -`dns` - The new list of DNS nameservers in JSON. +``` http +POST /api/v2/tailnet/{tailnet}/dns/nameservers ``` + +Replaces the list of global DNS nameservers for the given tailnet with the list supplied in the request. +Note that changing the list of DNS nameservers may also affect the status of MagicDNS (if MagicDNS is on; learn about [MagicDNS](https://tailscale.com/kb/1081). +If all nameservers have been removed, MagicDNS will be automatically disabled (until explicitly turned back on by the user). + +### Parameters + +#### `tailnet` (required in URL path) + +The tailnet organization name. + +#### `dns` (required in `POST` body) + +The new list of DNS nameservers in JSON. + +``` jsonc { "dns":["8.8.8.8"] } ``` -##### Returns -Returns the new list of nameservers and the status of MagicDNS. +### Request example: adding DNS nameservers with MagicDNS on -If all nameservers have been removed, MagicDNS will be automatically disabled (until explicitly turned back on by the user). +Adding DNS nameservers with the MagicDNS on: -##### Example -###### Adding DNS nameservers with the MagicDNS on: -``` -POST /api/v2/tailnet/example.com/dns/nameservers -curl -X POST 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/nameservers' \ - -u "tskey-yourapikey123:" \ +``` sh +curl "https://api.tailscale.com/api/v2/tailnet/example.com/dns/nameservers" \ + -u "tskey-api-xxxxx:" \ --data-binary '{"dns": ["8.8.8.8"]}' ``` -Response: -``` +### Response example: adding DNS nameservers, MagicDNS on + +The response is a JSON object containing the new list of nameservers and the status of MagicDNS. + +``` jsonc { "dns":["8.8.8.8"], "magicDNS":true, } ``` -###### Removing all DNS nameservers with the MagicDNS on: -``` -POST /api/v2/tailnet/example.com/dns/nameservers -curl -X POST 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/nameservers' \ - -u "tskey-yourapikey123:" \ +### Request example: removing all DNS nameservers, MagicDNS on + +``` sh +curl "https://api.tailscale.com/api/v2/tailnet/example.com/dns/nameservers" \ + -u "tskey-api-xxxxx:" \ --data-binary '{"dns": []}' ``` -Response: -``` +### Response example: removing all DNS nameservers with MagicDNS on + +The response is a JSON object containing the new list of nameservers and the status of MagicDNS. + +``` jsonc { "dns":[], "magicDNS": false, } ``` - + + +## Get DNS preferences + +``` http +GET /api/v2/tailnet/{tailnet}/dns/preferences` +``` -#### `GET /api/v2/tailnet/:tailnet/dns/preferences` - retrieves the DNS preferences for a tailnet Retrieves the DNS preferences that are currently set for the given tailnet. -Supply the tailnet of interest in the path. -##### Parameters -No parameters. +### Parameters -##### Example -``` -GET /api/v2/tailnet/example.com/dns/preferences -curl 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/preferences' \ - -u "tskey-yourapikey123:" -``` +#### `tailnet` (required in URL path) + +The tailnet organization name. + +### Request example -Response: +``` sh +curl "https://api.tailscale.com/api/v2/tailnet/example.com/dns/preferences" \ + -u "tskey-api-xxxxx:" ``` + +### Response + +``` jsonc { "magicDNS":false, } ``` - + + +## Set DNS preferences -#### `POST /api/v2/tailnet/:tailnet/dns/preferences` - replaces the DNS preferences for a tailnet -Replaces the DNS preferences for a tailnet, specifically, the MagicDNS setting. +``` http +POST /api/v2/tailnet/{tailnet}/dns/preferences +``` + +Set the DNS preferences for a tailnet; specifically, the MagicDNS setting. Note that MagicDNS is dependent on DNS servers. +Learn about [MagicDNS](https://tailscale.com/kb/1081). If there is at least one DNS server, then MagicDNS can be enabled. Otherwise, it returns an error. + Note that removing all nameservers will turn off MagicDNS. To reenable it, nameservers must be added back, and MagicDNS must be explicitly turned on. -##### Parameters -###### POST Body -The DNS preferences in JSON. Currently, MagicDNS is the only setting available. -`magicDNS` - Automatically registers DNS names for devices in your tailnet. -``` +### Parameters + +#### `tailnet` (required in URL path) + +The tailnet organization name. + +#### DNS preference (required in `POST` body) + +The DNS preferences in JSON. Currently, MagicDNS is the only setting available: + +- **`magicDNS`:** Automatically registers DNS names for devices in your tailnet. + +``` jsonc { "magicDNS": true } ``` -##### Example -``` -POST /api/v2/tailnet/example.com/dns/preferences -curl -X POST 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/preferences' \ - -u "tskey-yourapikey123:" \ +### Request example + +``` sh +curl "https://api.tailscale.com/api/v2/tailnet/example.com/dns/preferences" \ + -u "tskey-api-xxxxx:" \ --data-binary '{"magicDNS": true}' ``` +### Response -Response: +If there are no DNS servers, this returns an error message: -If there are no DNS servers, it returns an error message: -``` +``` jsonc { "message":"need at least one nameserver to enable MagicDNS" } ``` -If there are DNS servers: -``` +If there are DNS servers, this returns the MagicDNS status: + +``` jsonc { "magicDNS":true, } ``` - + -#### `GET /api/v2/tailnet/:tailnet/dns/searchpaths` - retrieves the search paths for a tailnet -Retrieves the list of search paths that is currently set for the given tailnet. -Supply the tailnet of interest in the path. +## Get search paths +``` http +GET /api/v2/tailnet/{tailnet}/dns/searchpaths +``` -##### Parameters -No parameters. +Retrieves the list of search paths, also referred to as _search domains_, that is currently set for the given tailnet. -##### Example -``` -GET /api/v2/tailnet/example.com/dns/searchpaths -curl 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/searchpaths' \ - -u "tskey-yourapikey123:" -``` +### Parameters -Response: +#### `tailnet` (required in URL path) + +The tailnet organization name. + +### Request example + +``` sh +curl "https://api.tailscale.com/api/v2/tailnet/example.com/dns/searchpaths" \ + -u "tskey-api-xxxxx:" ``` + +### Response + +``` jsonc { "searchPaths": ["user1.example.com"], } ``` - - -#### `POST /api/v2/tailnet/:tailnet/dns/searchpaths` - replaces the search paths for a tailnet -Replaces the list of searchpaths with the list supplied by the user and returns an error otherwise. + -##### Parameters +## Set search paths -###### POST Body -`searchPaths` - A list of searchpaths in JSON. +``` http +POST /api/v2/tailnet/{tailnet}/dns/searchpaths ``` + +Replaces the list of search paths with the list supplied by the user and returns an error otherwise. + +### Parameters + +#### `tailnet` (required in URL path) + +The tailnet organization name. + +#### `searchPaths` (required in `POST` body) + +Specify a list of search paths in a JSON object: + +``` jsonc { "searchPaths": ["user1.example.com", "user2.example.com"] } ``` -##### Example -``` -POST /api/v2/tailnet/example.com/dns/searchpaths -curl -X POST 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/searchpaths' \ - -u "tskey-yourapikey123:" \ +### Request example + +``` sh +curl "https://api.tailscale.com/api/v2/tailnet/example.com/dns/searchpaths" \ + -u "tskey-api-xxxxx:" \ --data-binary '{"searchPaths": ["user1.example.com", "user2.example.com"]}' ``` -Response: -``` +### Response + +The response is a JSON object containing the new list of search paths. + +``` jsonc { "searchPaths": ["user1.example.com", "user2.example.com"], } diff --git a/atomicfile/atomicfile.go b/atomicfile/atomicfile.go index b63955a920c3b..5c18e85a896eb 100644 --- a/atomicfile/atomicfile.go +++ b/atomicfile/atomicfile.go @@ -8,14 +8,20 @@ package atomicfile // import "tailscale.com/atomicfile" import ( + "fmt" "os" "path/filepath" "runtime" ) -// WriteFile writes data to filename+some suffix, then renames it -// into filename. The perm argument is ignored on Windows. +// WriteFile writes data to filename+some suffix, then renames it into filename. +// The perm argument is ignored on Windows. If the target filename already +// exists but is not a regular file, WriteFile returns an error. func WriteFile(filename string, data []byte, perm os.FileMode) (err error) { + fi, err := os.Stat(filename) + if err == nil && !fi.Mode().IsRegular() { + return fmt.Errorf("%s already exists and is not a regular file", filename) + } f, err := os.CreateTemp(filepath.Dir(filename), filepath.Base(filename)+".tmp") if err != nil { return err diff --git a/atomicfile/atomicfile_test.go b/atomicfile/atomicfile_test.go new file mode 100644 index 0000000000000..78c93e664f738 --- /dev/null +++ b/atomicfile/atomicfile_test.go @@ -0,0 +1,47 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !js && !windows + +package atomicfile + +import ( + "net" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestDoesNotOverwriteIrregularFiles(t *testing.T) { + // Per tailscale/tailscale#7658 as one example, almost any imagined use of + // atomicfile.Write should likely not attempt to overwrite an irregular file + // such as a device node, socket, or named pipe. + + const filename = "TestDoesNotOverwriteIrregularFiles" + var path string + // macOS private temp does not allow unix socket creation, but /tmp does. + if runtime.GOOS == "darwin" { + path = filepath.Join("/tmp", filename) + t.Cleanup(func() { os.Remove(path) }) + } else { + path = filepath.Join(t.TempDir(), filename) + } + + // The least troublesome thing to make that is not a file is a unix socket. + // Making a null device sadly requires root. + l, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"}) + if err != nil { + t.Fatal(err) + } + defer l.Close() + + err = WriteFile(path, []byte("hello"), 0644) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "is not a regular file") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/build_dist.sh b/build_dist.sh index 5b7370bda74cf..8a77bf8a74b38 100755 --- a/build_dist.sh +++ b/build_dist.sh @@ -11,42 +11,25 @@ set -eu -IFS=".$IFS" read -r major minor patch /dev/null; then - patch="$change_count" - change_suffix="" -elif [ "$change_count" != "0" ]; then - change_suffix="-$change_count" -else - change_suffix="" -fi - -long_suffix="$change_suffix-t$short_hash" -MINOR="$major.$minor" -SHORT="$MINOR.$patch" -LONG="${SHORT}$long_suffix" -GIT_HASH="$git_hash" +eval `GOOS=$($go env GOHOSTOS) GOARCH=$($go env GOHOSTARCH) $go run ./cmd/mkversion` if [ "$1" = "shellvars" ]; then cat < 0 { - return false, multierr.New(errs...) - } - ok, err := checkPermission(ctx, "patch", secretName) - if err != nil { - log.Printf("error checking patch permission on secret %s: %v", secretName, err) - return false, nil - } - return ok, nil -} - -// checkPermission reports whether the current pod has permission to use the -// given verb (e.g. get, update, patch) on secretName. -func checkPermission(ctx context.Context, verb, secretName string) (bool, error) { - sar := map[string]any{ - "apiVersion": "authorization.k8s.io/v1", - "kind": "SelfSubjectAccessReview", - "spec": map[string]any{ - "resourceAttributes": map[string]any{ - "namespace": kubeNamespace, - "verb": verb, - "resource": "secrets", - "name": secretName, - }, - }, - } - bs, err := json.Marshal(sar) - if err != nil { - return false, err - } - req, err := http.NewRequest("POST", "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews", bytes.NewReader(bs)) - if err != nil { - return false, err - } - resp, err := doKubeRequest(ctx, req) - if err != nil { - return false, err - } - defer resp.Body.Close() - bs, err = io.ReadAll(resp.Body) - if err != nil { - return false, err - } - var res struct { - Status struct { - Allowed bool `json:"allowed"` - } `json:"status"` - } - if err := json.Unmarshal(bs, &res); err != nil { - return false, err - } - return res.Status.Allowed, nil -} - // findKeyInKubeSecret inspects the kube secret secretName for a data // field called "authkey", and returns its value if present. func findKeyInKubeSecret(ctx context.Context, secretName string) (string, error) { - req, err := http.NewRequest("GET", fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s", kubeNamespace, secretName), nil) - if err != nil { - return "", err - } - resp, err := doKubeRequest(ctx, req) - if err != nil { - if resp != nil && resp.StatusCode == http.StatusNotFound { - // Kube secret doesn't exist yet, can't have an authkey. - return "", nil - } - return "", err - } - defer resp.Body.Close() - - bs, err := io.ReadAll(resp.Body) + s, err := kc.GetSecret(ctx, secretName) if err != nil { return "", err } - - // We use a map[string]any here rather than import corev1.Secret, - // because we only do very limited things to the secret, and - // importing corev1 adds 12MiB to the compiled binary. - var s map[string]any - if err := json.Unmarshal(bs, &s); err != nil { - return "", err + ak, ok := s.Data["authkey"] + if !ok { + return "", nil } - if d, ok := s["data"].(map[string]any); ok { - if v, ok := d["authkey"].(string); ok { - bs, err := base64.StdEncoding.DecodeString(v) - if err != nil { - return "", err - } - return string(bs), nil - } - } - return "", nil + return string(ak), nil } // storeDeviceInfo writes deviceID into the "device_id" data field of the kube @@ -145,65 +35,38 @@ func findKeyInKubeSecret(ctx context.Context, secretName string) (string, error) func storeDeviceInfo(ctx context.Context, secretName string, deviceID tailcfg.StableNodeID, fqdn string) error { // First check if the secret exists at all. Even if running on // kubernetes, we do not necessarily store state in a k8s secret. - req, err := http.NewRequest("GET", fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s", kubeNamespace, secretName), nil) - if err != nil { - return err - } - resp, err := doKubeRequest(ctx, req) - if err != nil { - if resp != nil && resp.StatusCode >= 400 && resp.StatusCode <= 499 { - // Assume the secret doesn't exist, or we don't have - // permission to access it. - return nil + if _, err := kc.GetSecret(ctx, secretName); err != nil { + if s, ok := err.(*kube.Status); ok { + if s.Code >= 400 && s.Code <= 499 { + // Assume the secret doesn't exist, or we don't have + // permission to access it. + return nil + } } return err } - m := map[string]map[string]string{ - "stringData": { - "device_id": string(deviceID), - "device_fqdn": fqdn, + m := &kube.Secret{ + Data: map[string][]byte{ + "device_id": []byte(deviceID), + "device_fqdn": []byte(fqdn), }, } - var b bytes.Buffer - if err := json.NewEncoder(&b).Encode(m); err != nil { - return err - } - req, err = http.NewRequest("PATCH", fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s?fieldManager=tailscale-container", kubeNamespace, secretName), &b) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/strategic-merge-patch+json") - if _, err := doKubeRequest(ctx, req); err != nil { - return err - } - return nil + return kc.StrategicMergePatchSecret(ctx, secretName, m, "tailscale-container") } // deleteAuthKey deletes the 'authkey' field of the given kube // secret. No-op if there is no authkey in the secret. func deleteAuthKey(ctx context.Context, secretName string) error { // m is a JSON Patch data structure, see https://jsonpatch.com/ or RFC 6902. - m := []struct { - Op string `json:"op"` - Path string `json:"path"` - }{ + m := []kube.JSONPatch{ { Op: "remove", Path: "/data/authkey", }, } - var b bytes.Buffer - if err := json.NewEncoder(&b).Encode(m); err != nil { - return err - } - req, err := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s?fieldManager=tailscale-container", kubeNamespace, secretName), &b) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json-patch+json") - if resp, err := doKubeRequest(ctx, req); err != nil { - if resp != nil && resp.StatusCode == http.StatusUnprocessableEntity { + if err := kc.JSONPatchSecret(ctx, secretName, m); err != nil { + if s, ok := err.(*kube.Status); ok && s.Code == http.StatusUnprocessableEntity { // This is kubernetes-ese for "the field you asked to // delete already doesn't exist", aka no-op. return nil @@ -213,65 +76,22 @@ func deleteAuthKey(ctx context.Context, secretName string) error { return nil } -var ( - kubeHost string - kubeNamespace string - kubeToken string - kubeHTTP *http.Transport -) +var kc *kube.Client func initKube(root string) { - // If running in Kubernetes, set things up so that doKubeRequest - // can talk successfully to the kube apiserver. - if os.Getenv("KUBERNETES_SERVICE_HOST") == "" { - return + if root != "/" { + // If we are running in a test, we need to set the root path to the fake + // service account directory. + kube.SetRootPathForTesting(root) } - - kubeHost = os.Getenv("KUBERNETES_SERVICE_HOST") + ":" + os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS") - - bs, err := os.ReadFile(filepath.Join(root, "var/run/secrets/kubernetes.io/serviceaccount/namespace")) - if err != nil { - log.Fatalf("Error reading kube namespace: %v", err) - } - kubeNamespace = strings.TrimSpace(string(bs)) - - bs, err = os.ReadFile(filepath.Join(root, "var/run/secrets/kubernetes.io/serviceaccount/token")) - if err != nil { - log.Fatalf("Error reading kube token: %v", err) - } - kubeToken = strings.TrimSpace(string(bs)) - - bs, err = os.ReadFile(filepath.Join(root, "var/run/secrets/kubernetes.io/serviceaccount/ca.crt")) - if err != nil { - log.Fatalf("Error reading kube CA cert: %v", err) - } - cp := x509.NewCertPool() - cp.AppendCertsFromPEM(bs) - kubeHTTP = &http.Transport{ - TLSClientConfig: &tls.Config{ - RootCAs: cp, - }, - IdleConnTimeout: time.Second, - } -} - -// doKubeRequest sends r to the kube apiserver. -func doKubeRequest(ctx context.Context, r *http.Request) (*http.Response, error) { - if kubeHTTP == nil { - panic("not in kubernetes") - } - - r.URL.Scheme = "https" - r.URL.Host = kubeHost - r.Header.Set("Authorization", "Bearer "+kubeToken) - r.Header.Set("Accept", "application/json") - - resp, err := kubeHTTP.RoundTrip(r) + var err error + kc, err = kube.New() if err != nil { - return nil, err + log.Fatalf("Error creating kube client: %v", err) } - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - return resp, fmt.Errorf("got non-200/201 status code %d", resp.StatusCode) + if root != "/" { + // If we are running in a test, we need to set the URL to the + // httptest server. + kc.SetURL(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS"))) } - return resp, nil } diff --git a/cmd/containerboot/main.go b/cmd/containerboot/main.go index dc382fa253004..314251e39c245 100644 --- a/cmd/containerboot/main.go +++ b/cmd/containerboot/main.go @@ -123,7 +123,7 @@ func main() { defer cancel() if cfg.InKubernetes && cfg.KubeSecret != "" { - canPatch, err := checkSecretPermissions(ctx, cfg.KubeSecret) + canPatch, err := kc.CheckSecretPermissions(ctx, cfg.KubeSecret) if err != nil { log.Fatalf("Some Kubernetes permissions are missing, please check your RBAC configuration: %v", err) } diff --git a/cmd/containerboot/main_test.go b/cmd/containerboot/main_test.go index 8f4c223029bf5..704553a2a1a9c 100644 --- a/cmd/containerboot/main_test.go +++ b/cmd/containerboot/main_test.go @@ -607,7 +607,7 @@ func TestContainerBoot(t *testing.T) { }() var wantCmds []string - for _, p := range test.Phases { + for i, p := range test.Phases { lapi.Notify(p.Notify) wantCmds = append(wantCmds, p.WantCmds...) waitArgs(t, 2*time.Second, d, argFile, strings.Join(wantCmds, "\n")) @@ -626,7 +626,7 @@ func TestContainerBoot(t *testing.T) { return nil }) if err != nil { - t.Fatal(err) + t.Fatalf("phase %d: %v", i, err) } err = tstest.WaitFor(2*time.Second, func() error { for path, want := range p.WantFiles { @@ -983,13 +983,13 @@ func (k *kubeServer) serveSecret(w http.ResponseWriter, r *http.Request) { } case "application/strategic-merge-patch+json": req := struct { - Data map[string]string `json:"stringData"` + Data map[string][]byte `json:"data"` }{} if err := json.Unmarshal(bs, &req); err != nil { panic(fmt.Sprintf("json decode failed: %v. Body:\n\n%s", err, string(bs))) } for key, val := range req.Data { - k.secret[key] = val + k.secret[key] = string(val) } default: panic(fmt.Sprintf("unknown content type %q", r.Header.Get("Content-Type"))) diff --git a/cmd/derper/bootstrap_dns.go b/cmd/derper/bootstrap_dns.go index 134cfad936c31..e7d96f4667638 100644 --- a/cmd/derper/bootstrap_dns.go +++ b/cmd/derper/bootstrap_dns.go @@ -14,6 +14,7 @@ import ( "time" "tailscale.com/syncs" + "tailscale.com/util/slicesx" ) const refreshTimeout = time.Minute @@ -52,6 +53,13 @@ func refreshBootstrapDNS() { ctx, cancel := context.WithTimeout(context.Background(), refreshTimeout) defer cancel() dnsEntries := resolveList(ctx, strings.Split(*bootstrapDNS, ",")) + // Randomize the order of the IPs for each name to avoid the client biasing + // to IPv6 + for k := range dnsEntries { + ips := dnsEntries[k] + slicesx.Shuffle(ips) + dnsEntries[k] = ips + } j, err := json.MarshalIndent(dnsEntries, "", "\t") if err != nil { // leave the old values in place diff --git a/cmd/derper/bootstrap_dns_test.go b/cmd/derper/bootstrap_dns_test.go index 70d3c867815fc..cbede85876466 100644 --- a/cmd/derper/bootstrap_dns_test.go +++ b/cmd/derper/bootstrap_dns_test.go @@ -11,14 +11,12 @@ import ( "net/url" "reflect" "testing" + + "tailscale.com/tstest" ) func BenchmarkHandleBootstrapDNS(b *testing.B) { - prev := *bootstrapDNS - *bootstrapDNS = "log.tailscale.io,login.tailscale.com,controlplane.tailscale.com,login.us.tailscale.com" - defer func() { - *bootstrapDNS = prev - }() + tstest.Replace(b, bootstrapDNS, "log.tailscale.io,login.tailscale.com,controlplane.tailscale.com,login.us.tailscale.com") refreshBootstrapDNS() w := new(bitbucketResponseWriter) req, _ := http.NewRequest("GET", "https://localhost/bootstrap-dns?q="+url.QueryEscape("log.tailscale.io"), nil) diff --git a/cmd/derper/depaware.txt b/cmd/derper/depaware.txt index 529494f56d2d7..7f60654b09483 100644 --- a/cmd/derper/depaware.txt +++ b/cmd/derper/depaware.txt @@ -8,21 +8,63 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/internal/common+ W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy + github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus + 💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus github.com/fxamacker/cbor/v2 from tailscale.com/tka github.com/golang/groupcache/lru from tailscale.com/net/dnscache + github.com/golang/protobuf/proto from github.com/matttproud/golang_protobuf_extensions/pbutil+ + github.com/golang/protobuf/ptypes/timestamp from github.com/prometheus/client_model/go github.com/hdevalence/ed25519consensus from tailscale.com/tka L github.com/josharian/native from github.com/mdlayher/netlink+ L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink github.com/klauspost/compress/flate from nhooyr.io/websocket + github.com/matttproud/golang_protobuf_extensions/pbutil from github.com/prometheus/common/expfmt L 💣 github.com/mdlayher/netlink from github.com/jsimonetti/rtnetlink+ L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+ L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink 💣 github.com/mitchellh/go-ps from tailscale.com/safesocket + 💣 github.com/prometheus/client_golang/prometheus from tailscale.com/tsweb/promvarz + github.com/prometheus/client_golang/prometheus/internal from github.com/prometheus/client_golang/prometheus + github.com/prometheus/client_model/go from github.com/prometheus/client_golang/prometheus+ + github.com/prometheus/common/expfmt from github.com/prometheus/client_golang/prometheus+ + github.com/prometheus/common/internal/bitbucket.org/ww/goautoneg from github.com/prometheus/common/expfmt + github.com/prometheus/common/model from github.com/prometheus/client_golang/prometheus+ + LD github.com/prometheus/procfs from github.com/prometheus/client_golang/prometheus + LD github.com/prometheus/procfs/internal/fs from github.com/prometheus/procfs + LD github.com/prometheus/procfs/internal/util from github.com/prometheus/procfs github.com/x448/float16 from github.com/fxamacker/cbor/v2 💣 go4.org/mem from tailscale.com/client/tailscale+ go4.org/netipx from tailscale.com/wgengine/filter W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/interfaces+ + google.golang.org/protobuf/encoding/prototext from github.com/golang/protobuf/proto+ + google.golang.org/protobuf/encoding/protowire from github.com/golang/protobuf/proto+ + google.golang.org/protobuf/internal/descfmt from google.golang.org/protobuf/internal/filedesc + google.golang.org/protobuf/internal/descopts from google.golang.org/protobuf/internal/filedesc+ + google.golang.org/protobuf/internal/detrand from google.golang.org/protobuf/internal/descfmt+ + google.golang.org/protobuf/internal/encoding/defval from google.golang.org/protobuf/internal/encoding/tag+ + google.golang.org/protobuf/internal/encoding/messageset from google.golang.org/protobuf/encoding/prototext+ + google.golang.org/protobuf/internal/encoding/tag from google.golang.org/protobuf/internal/impl + google.golang.org/protobuf/internal/encoding/text from google.golang.org/protobuf/encoding/prototext+ + google.golang.org/protobuf/internal/errors from google.golang.org/protobuf/encoding/prototext+ + google.golang.org/protobuf/internal/filedesc from google.golang.org/protobuf/internal/encoding/tag+ + google.golang.org/protobuf/internal/filetype from google.golang.org/protobuf/runtime/protoimpl + google.golang.org/protobuf/internal/flags from google.golang.org/protobuf/encoding/prototext+ + google.golang.org/protobuf/internal/genid from google.golang.org/protobuf/encoding/prototext+ + 💣 google.golang.org/protobuf/internal/impl from google.golang.org/protobuf/internal/filetype+ + google.golang.org/protobuf/internal/order from google.golang.org/protobuf/encoding/prototext+ + google.golang.org/protobuf/internal/pragma from google.golang.org/protobuf/encoding/prototext+ + google.golang.org/protobuf/internal/set from google.golang.org/protobuf/encoding/prototext + 💣 google.golang.org/protobuf/internal/strs from google.golang.org/protobuf/encoding/prototext+ + google.golang.org/protobuf/internal/version from google.golang.org/protobuf/runtime/protoimpl + google.golang.org/protobuf/proto from github.com/golang/protobuf/proto+ + google.golang.org/protobuf/reflect/protodesc from github.com/golang/protobuf/proto + 💣 google.golang.org/protobuf/reflect/protoreflect from github.com/golang/protobuf/proto+ + google.golang.org/protobuf/reflect/protoregistry from github.com/golang/protobuf/proto+ + google.golang.org/protobuf/runtime/protoiface from github.com/golang/protobuf/proto+ + google.golang.org/protobuf/runtime/protoimpl from github.com/golang/protobuf/proto+ + google.golang.org/protobuf/types/descriptorpb from google.golang.org/protobuf/reflect/protodesc + google.golang.org/protobuf/types/known/timestamppb from github.com/golang/protobuf/ptypes/timestamp+ nhooyr.io/websocket from tailscale.com/cmd/derper+ nhooyr.io/websocket/internal/errd from nhooyr.io/websocket nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket @@ -47,6 +89,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa tailscale.com/net/netns from tailscale.com/derp/derphttp tailscale.com/net/netutil from tailscale.com/client/tailscale tailscale.com/net/packet from tailscale.com/wgengine/filter + tailscale.com/net/sockstats from tailscale.com/derp/derphttp tailscale.com/net/stun from tailscale.com/cmd/derper tailscale.com/net/tlsdial from tailscale.com/derp/derphttp tailscale.com/net/tsaddr from tailscale.com/ipn+ @@ -59,12 +102,15 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa tailscale.com/tka from tailscale.com/client/tailscale+ W tailscale.com/tsconst from tailscale.com/net/interfaces 💣 tailscale.com/tstime/mono from tailscale.com/tstime/rate - tailscale.com/tstime/rate from tailscale.com/wgengine/filter + tailscale.com/tstime/rate from tailscale.com/wgengine/filter+ tailscale.com/tsweb from tailscale.com/cmd/derper + tailscale.com/tsweb/promvarz from tailscale.com/tsweb + tailscale.com/tsweb/varz from tailscale.com/tsweb+ tailscale.com/types/dnstype from tailscale.com/tailcfg tailscale.com/types/empty from tailscale.com/ipn tailscale.com/types/ipproto from tailscale.com/net/flowtrack+ tailscale.com/types/key from tailscale.com/cmd/derper+ + tailscale.com/types/lazy from tailscale.com/version+ tailscale.com/types/logger from tailscale.com/cmd/derper+ tailscale.com/types/netmap from tailscale.com/ipn tailscale.com/types/opt from tailscale.com/client/tailscale+ @@ -81,10 +127,11 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa tailscale.com/util/dnsname from tailscale.com/hostinfo+ tailscale.com/util/httpm from tailscale.com/client/tailscale tailscale.com/util/lineread from tailscale.com/hostinfo+ - tailscale.com/util/mak from tailscale.com/syncs + tailscale.com/util/mak from tailscale.com/syncs+ tailscale.com/util/multierr from tailscale.com/health tailscale.com/util/set from tailscale.com/health tailscale.com/util/singleflight from tailscale.com/net/dnscache + tailscale.com/util/slicesx from tailscale.com/cmd/derper+ tailscale.com/util/vizerror from tailscale.com/tsweb W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+ tailscale.com/version from tailscale.com/derp+ @@ -109,7 +156,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa L golang.org/x/net/bpf from github.com/mdlayher/netlink+ golang.org/x/net/dns/dnsmessage from net+ golang.org/x/net/http/httpguts from net/http - golang.org/x/net/http/httpproxy from net/http + golang.org/x/net/http/httpproxy from net/http+ golang.org/x/net/http2/hpack from net/http golang.org/x/net/idna from golang.org/x/crypto/acme/autocert+ golang.org/x/net/proxy from tailscale.com/net/netns @@ -166,8 +213,10 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa expvar from tailscale.com/cmd/derper+ flag from tailscale.com/cmd/derper fmt from compress/flate+ + go/token from google.golang.org/protobuf/internal/strs hash from crypto+ hash/crc32 from compress/gzip+ + hash/fnv from google.golang.org/protobuf/internal/detrand hash/maphash from go4.org/mem html from net/http/pprof+ io from bufio+ @@ -185,7 +234,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa net/http from expvar+ net/http/httptrace from net/http+ net/http/internal from net/http - net/http/pprof from tailscale.com/tsweb + net/http/pprof from tailscale.com/tsweb+ net/netip from go4.org/netipx+ net/textproto from golang.org/x/net/http/httpguts+ net/url from crypto/x509+ @@ -198,6 +247,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa regexp from internal/profile+ regexp/syntax from regexp runtime/debug from golang.org/x/crypto/acme+ + runtime/metrics from github.com/prometheus/client_golang/prometheus+ runtime/pprof from net/http/pprof runtime/trace from net/http/pprof sort from compress/flate+ diff --git a/cmd/derper/derper.go b/cmd/derper/derper.go index 48efaba40c53f..02736b6bec6c8 100644 --- a/cmd/derper/derper.go +++ b/cmd/derper/derper.go @@ -36,8 +36,8 @@ import ( ) var ( - dev = flag.Bool("dev", false, "run in localhost development mode") - addr = flag.String("a", ":443", "server HTTPS listen address, in form \":port\", \"ip:port\", or for IPv6 \"[ip]:port\". If the IP is omitted, it defaults to all interfaces.") + dev = flag.Bool("dev", false, "run in localhost development mode (overrides -a)") + addr = flag.String("a", ":443", "server HTTP/HTTPS listen address, in form \":port\", \"ip:port\", or for IPv6 \"[ip]:port\". If the IP is omitted, it defaults to all interfaces. Serves HTTPS if the port is 443 and/or -certmode is manual, otherwise HTTP.") httpPort = flag.Int("http-port", 80, "The port on which to serve HTTP. Set to -1 to disable. The listener is bound to the same IP (if any) as specified in the -a flag.") stunPort = flag.Int("stun-port", 3478, "The UDP port on which to serve STUN. The listener is bound to the same IP (if any) as specified in the -a flag.") configPath = flag.String("c", "", "config file path") diff --git a/cmd/derpprobe/derpprobe.go b/cmd/derpprobe/derpprobe.go index 56e08ae64f51a..6217eece33087 100644 --- a/cmd/derpprobe/derpprobe.go +++ b/cmd/derpprobe/derpprobe.go @@ -5,7 +5,6 @@ package main import ( - "expvar" "flag" "fmt" "html" @@ -23,13 +22,14 @@ var ( derpMapURL = flag.String("derp-map", "https://login.tailscale.com/derpmap/default", "URL to DERP map (https:// or file://)") listen = flag.String("listen", ":8030", "HTTP listen address") probeOnce = flag.Bool("once", false, "probe once and print results, then exit; ignores the listen flag") + spread = flag.Bool("spread", true, "whether to spread probing over time") interval = flag.Duration("interval", 15*time.Second, "probe interval") ) func main() { flag.Parse() - p := prober.New().WithSpread(true).WithOnce(*probeOnce) + p := prober.New().WithSpread(*spread).WithOnce(*probeOnce).WithMetricNamespace("derpprobe") dp, err := prober.DERP(p, *derpMapURL, *interval, *interval, *interval) if err != nil { log.Fatal(err) @@ -52,7 +52,6 @@ func main() { mux := http.NewServeMux() tsweb.Debugger(mux) - expvar.Publish("derpprobe", p.Expvar()) mux.HandleFunc("/", http.HandlerFunc(serveFunc(p))) log.Fatal(http.ListenAndServe(*listen, mux)) } diff --git a/cmd/dist/dist.go b/cmd/dist/dist.go new file mode 100644 index 0000000000000..04d1c57a927c1 --- /dev/null +++ b/cmd/dist/dist.go @@ -0,0 +1,28 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// The dist command builds Tailscale release packages for distribution. +package main + +import ( + "context" + "errors" + "flag" + "log" + "os" + + "tailscale.com/release/dist" + "tailscale.com/release/dist/cli" + "tailscale.com/release/dist/unixpkgs" +) + +func getTargets() ([]dist.Target, error) { + return unixpkgs.Targets(), nil +} + +func main() { + cmd := cli.CLI(getTargets) + if err := cmd.ParseAndRun(context.Background(), os.Args[1:]); err != nil && !errors.Is(err, flag.ErrHelp) { + log.Fatal(err) + } +} diff --git a/cmd/get-authkey/main.go b/cmd/get-authkey/main.go index f638847bf3e9c..fb8f6abe7caa1 100644 --- a/cmd/get-authkey/main.go +++ b/cmd/get-authkey/main.go @@ -2,7 +2,7 @@ // SPDX-License-Identifier: BSD-3-Clause // get-authkey allocates an authkey using an OAuth API client -// https://tailscale.com/kb/1215/oauth-clients/ and prints it +// https://tailscale.com/s/oauth-clients and prints it // to stdout for scripts to capture and use. package main diff --git a/cmd/gitops-pusher/gitops-pusher.go b/cmd/gitops-pusher/gitops-pusher.go index 9cfdea604f29a..dd48a723a37b3 100644 --- a/cmd/gitops-pusher/gitops-pusher.go +++ b/cmd/gitops-pusher/gitops-pusher.go @@ -22,6 +22,7 @@ import ( "github.com/peterbourgon/ff/v3/ffcli" "github.com/tailscale/hujson" + "golang.org/x/oauth2/clientcredentials" "tailscale.com/util/httpm" ) @@ -42,9 +43,9 @@ func modifiedExternallyError() { } } -func apply(cache *Cache, tailnet, apiKey string) func(context.Context, []string) error { +func apply(cache *Cache, client *http.Client, tailnet, apiKey string) func(context.Context, []string) error { return func(ctx context.Context, args []string) error { - controlEtag, err := getACLETag(ctx, tailnet, apiKey) + controlEtag, err := getACLETag(ctx, client, tailnet, apiKey) if err != nil { return err } @@ -73,7 +74,7 @@ func apply(cache *Cache, tailnet, apiKey string) func(context.Context, []string) return nil } - if err := applyNewACL(ctx, tailnet, apiKey, *policyFname, controlEtag); err != nil { + if err := applyNewACL(ctx, client, tailnet, apiKey, *policyFname, controlEtag); err != nil { return err } @@ -83,9 +84,9 @@ func apply(cache *Cache, tailnet, apiKey string) func(context.Context, []string) } } -func test(cache *Cache, tailnet, apiKey string) func(context.Context, []string) error { +func test(cache *Cache, client *http.Client, tailnet, apiKey string) func(context.Context, []string) error { return func(ctx context.Context, args []string) error { - controlEtag, err := getACLETag(ctx, tailnet, apiKey) + controlEtag, err := getACLETag(ctx, client, tailnet, apiKey) if err != nil { return err } @@ -113,16 +114,16 @@ func test(cache *Cache, tailnet, apiKey string) func(context.Context, []string) return nil } - if err := testNewACLs(ctx, tailnet, apiKey, *policyFname); err != nil { + if err := testNewACLs(ctx, client, tailnet, apiKey, *policyFname); err != nil { return err } return nil } } -func getChecksums(cache *Cache, tailnet, apiKey string) func(context.Context, []string) error { +func getChecksums(cache *Cache, client *http.Client, tailnet, apiKey string) func(context.Context, []string) error { return func(ctx context.Context, args []string) error { - controlEtag, err := getACLETag(ctx, tailnet, apiKey) + controlEtag, err := getACLETag(ctx, client, tailnet, apiKey) if err != nil { return err } @@ -151,8 +152,24 @@ func main() { log.Fatal("set envvar TS_TAILNET to your tailnet's name") } apiKey, ok := os.LookupEnv("TS_API_KEY") - if !ok { - log.Fatal("set envvar TS_API_KEY to your Tailscale API key") + oauthId, oiok := os.LookupEnv("TS_OAUTH_ID") + oauthSecret, osok := os.LookupEnv("TS_OAUTH_SECRET") + if !ok && (!oiok || !osok) { + log.Fatal("set envvar TS_API_KEY to your Tailscale API key or TS_OAUTH_ID and TS_OAUTH_SECRET to your Tailscale OAuth ID and Secret") + } + if ok && (oiok || osok) { + log.Fatal("set either the envvar TS_API_KEY or TS_OAUTH_ID and TS_OAUTH_SECRET") + } + var client *http.Client + if oiok { + oauthConfig := &clientcredentials.Config{ + ClientID: oauthId, + ClientSecret: oauthSecret, + TokenURL: fmt.Sprintf("https://%s/api/v2/oauth/token", *apiServer), + } + client = oauthConfig.Client(context.Background()) + } else { + client = http.DefaultClient } cache, err := LoadCache(*cacheFname) if err != nil { @@ -169,7 +186,7 @@ func main() { ShortUsage: "gitops-pusher [options] apply", ShortHelp: "Pushes changes to CONTROL", LongHelp: `Pushes changes to CONTROL`, - Exec: apply(cache, tailnet, apiKey), + Exec: apply(cache, client, tailnet, apiKey), } testCmd := &ffcli.Command{ @@ -177,7 +194,7 @@ func main() { ShortUsage: "gitops-pusher [options] test", ShortHelp: "Tests ACL changes", LongHelp: "Tests ACL changes", - Exec: test(cache, tailnet, apiKey), + Exec: test(cache, client, tailnet, apiKey), } cksumCmd := &ffcli.Command{ @@ -185,7 +202,7 @@ func main() { ShortUsage: "Shows checksums of ACL files", ShortHelp: "Fetch checksum of CONTROL's ACL and the local ACL for comparison", LongHelp: "Fetch checksum of CONTROL's ACL and the local ACL for comparison", - Exec: getChecksums(cache, tailnet, apiKey), + Exec: getChecksums(cache, client, tailnet, apiKey), } root := &ffcli.Command{ @@ -228,7 +245,7 @@ func sumFile(fname string) (string, error) { return fmt.Sprintf("%x", h.Sum(nil)), nil } -func applyNewACL(ctx context.Context, tailnet, apiKey, policyFname, oldEtag string) error { +func applyNewACL(ctx context.Context, client *http.Client, tailnet, apiKey, policyFname, oldEtag string) error { fin, err := os.Open(policyFname) if err != nil { return err @@ -244,7 +261,7 @@ func applyNewACL(ctx context.Context, tailnet, apiKey, policyFname, oldEtag stri req.Header.Set("Content-Type", "application/hujson") req.Header.Set("If-Match", `"`+oldEtag+`"`) - resp, err := http.DefaultClient.Do(req) + resp, err := client.Do(req) if err != nil { return err } @@ -265,7 +282,7 @@ func applyNewACL(ctx context.Context, tailnet, apiKey, policyFname, oldEtag stri return nil } -func testNewACLs(ctx context.Context, tailnet, apiKey, policyFname string) error { +func testNewACLs(ctx context.Context, client *http.Client, tailnet, apiKey, policyFname string) error { data, err := os.ReadFile(policyFname) if err != nil { return err @@ -283,7 +300,7 @@ func testNewACLs(ctx context.Context, tailnet, apiKey, policyFname string) error req.SetBasicAuth(apiKey, "") req.Header.Set("Content-Type", "application/hujson") - resp, err := http.DefaultClient.Do(req) + resp, err := client.Do(req) if err != nil { return err } @@ -346,7 +363,7 @@ type ACLTestErrorDetail struct { Errors []string `json:"errors"` } -func getACLETag(ctx context.Context, tailnet, apiKey string) (string, error) { +func getACLETag(ctx context.Context, client *http.Client, tailnet, apiKey string) (string, error) { req, err := http.NewRequestWithContext(ctx, httpm.GET, fmt.Sprintf("https://%s/api/v2/tailnet/%s/acl", *apiServer, tailnet), nil) if err != nil { return "", err @@ -355,7 +372,7 @@ func getACLETag(ctx context.Context, tailnet, apiKey string) (string, error) { req.SetBasicAuth(apiKey, "") req.Header.Set("Accept", "application/hujson") - resp, err := http.DefaultClient.Do(req) + resp, err := client.Do(req) if err != nil { return "", err } diff --git a/cmd/k8s-operator/manifests/authproxy-rbac.yaml b/cmd/k8s-operator/manifests/authproxy-rbac.yaml index ae711a858600c..ddbdda32e476e 100644 --- a/cmd/k8s-operator/manifests/authproxy-rbac.yaml +++ b/cmd/k8s-operator/manifests/authproxy-rbac.yaml @@ -7,7 +7,7 @@ metadata: name: tailscale-auth-proxy rules: - apiGroups: [""] - resources: ["users"] + resources: ["users", "groups"] verbs: ["impersonate"] --- apiVersion: rbac.authorization.k8s.io/v1 diff --git a/cmd/k8s-operator/operator.go b/cmd/k8s-operator/operator.go index 5d9824de7c99b..5155337eeac0b 100644 --- a/cmd/k8s-operator/operator.go +++ b/cmd/k8s-operator/operator.go @@ -7,8 +7,10 @@ package main import ( "context" + "crypto/tls" _ "embed" "fmt" + "net/http" "os" "strings" "time" @@ -25,7 +27,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/rest" + "k8s.io/client-go/transport" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" @@ -39,10 +41,12 @@ import ( "sigs.k8s.io/controller-runtime/pkg/source" "sigs.k8s.io/yaml" "tailscale.com/client/tailscale" + "tailscale.com/hostinfo" "tailscale.com/ipn" "tailscale.com/ipn/store/kubestore" "tailscale.com/tsnet" "tailscale.com/types/logger" + "tailscale.com/types/opt" "tailscale.com/util/dnsname" ) @@ -61,7 +65,7 @@ func main() { clientSecretPath = defaultEnv("CLIENT_SECRET_FILE", "") image = defaultEnv("PROXY_IMAGE", "tailscale/tailscale:latest") tags = defaultEnv("PROXY_TAGS", "tag:k8s") - shouldRunAuthProxy = defaultEnv("AUTH_PROXY", "false") + shouldRunAuthProxy = defaultBool("AUTH_PROXY", false) ) var opts []kzap.Opts @@ -95,6 +99,13 @@ func main() { } tsClient := tailscale.NewClient("-", nil) tsClient.HTTPClient = credentials.Client(context.Background()) + + if shouldRunAuthProxy { + hostinfo.SetApp("k8s-operator-proxy") + } else { + hostinfo.SetApp("k8s-operator") + } + s := &tsnet.Server{ Hostname: hostname, Logf: zlog.Named("tailscaled").Debugf, @@ -157,7 +168,7 @@ waitOnline: loginDone = true case "NeedsMachineAuth": if !machineAuthShown { - startlog.Infof("Machine authorization required, please visit the admin panel to authorize") + startlog.Infof("Machine approval required, please visit the admin panel to approve") machineAuthShown = true } default: @@ -225,16 +236,26 @@ waitOnline: } startlog.Infof("Startup complete, operator running") - if shouldRunAuthProxy == "true" { - rc, err := rest.TransportFor(restConfig) + if shouldRunAuthProxy { + cfg, err := restConfig.TransportConfig() if err != nil { - startlog.Fatalf("could not get rest transport: %v", err) + startlog.Fatalf("could not get rest.TransportConfig(): %v", err) } - authProxyListener, err := s.Listen("tcp", ":443") + + // Kubernetes uses SPDY for exec and port-forward, however SPDY is + // incompatible with HTTP/2; so disable HTTP/2 in the proxy. + tr := http.DefaultTransport.(*http.Transport).Clone() + tr.TLSClientConfig, err = transport.TLSConfigFor(cfg) if err != nil { - startlog.Fatalf("could not listen on :443: %v", err) + startlog.Fatalf("could not get transport.TLSConfigFor(): %v", err) } - go runAuthProxy(lc, authProxyListener, rc, zlog.Named("auth-proxy").Infof) + tr.TLSNextProto = make(map[string]func(authority string, c *tls.Conn) http.RoundTripper) + + rt, err := transport.HTTPWrappersForConfig(cfg, tr) + if err != nil { + startlog.Fatalf("could not get rest.TransportConfig(): %v", err) + } + go runAuthProxy(s, rt, zlog.Named("auth-proxy").Infof) } if err := mgr.Start(signals.SetupSignalHandler()); err != nil { startlog.Fatalf("could not start manager: %v", err) @@ -696,6 +717,15 @@ func getSingleObject[T any, O ptrObject[T]](ctx context.Context, c client.Client return ret, nil } +func defaultBool(envName string, defVal bool) bool { + vs := os.Getenv(envName) + if vs == "" { + return defVal + } + v, _ := opt.Bool(vs).Get() + return v +} + func defaultEnv(envName, defVal string) string { v := os.Getenv(envName) if v == "" { diff --git a/cmd/k8s-operator/proxy.go b/cmd/k8s-operator/proxy.go index 83a4cfb81d014..1a307a226159a 100644 --- a/cmd/k8s-operator/proxy.go +++ b/cmd/k8s-operator/proxy.go @@ -8,7 +8,6 @@ import ( "crypto/tls" "fmt" "log" - "net" "net/http" "net/http/httputil" "net/url" @@ -17,6 +16,7 @@ import ( "tailscale.com/client/tailscale" "tailscale.com/client/tailscale/apitype" + "tailscale.com/tsnet" "tailscale.com/types/logger" ) @@ -41,23 +41,42 @@ func (h *authProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.rp.ServeHTTP(w, r) } -func runAuthProxy(lc *tailscale.LocalClient, ls net.Listener, rt http.RoundTripper, logf logger.Logf) { +// runAuthProxy runs an HTTP server that authenticates requests using the +// Tailscale LocalAPI and then proxies them to the Kubernetes API. +// It listens on :443 and uses the Tailscale HTTPS certificate. +// s will be started if it is not already running. +// rt is used to proxy requests to the Kubernetes API. +// +// It never returns. +func runAuthProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf) { + ln, err := s.Listen("tcp", ":443") + if err != nil { + log.Fatalf("could not listen on :443: %v", err) + } u, err := url.Parse(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS"))) if err != nil { log.Fatalf("runAuthProxy: failed to parse URL %v", err) } + + lc, err := s.LocalClient() + if err != nil { + log.Fatalf("could not get local client: %v", err) + } ap := &authProxy{ logf: logf, lc: lc, rp: &httputil.ReverseProxy{ Director: func(r *http.Request) { - // Replace the request with the user's identity. - who := r.Context().Value(whoIsKey{}).(*apitype.WhoIsResponse) - r.Header.Set("Impersonate-User", who.UserProfile.LoginName) + // We want to proxy to the Kubernetes API, but we want to use + // the caller's identity to do so. We do this by impersonating + // the caller using the Kubernetes User Impersonation feature: + // https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation - // Remove all authentication headers. + // Out of paranoia, remove all authentication headers that might + // have been set by the client. r.Header.Del("Authorization") r.Header.Del("Impersonate-Group") + r.Header.Del("Impersonate-User") r.Header.Del("Impersonate-Uid") for k := range r.Header { if strings.HasPrefix(k, "Impersonate-Extra-") { @@ -65,6 +84,19 @@ func runAuthProxy(lc *tailscale.LocalClient, ls net.Listener, rt http.RoundTripp } } + // Now add the impersonation headers that we want. + who := r.Context().Value(whoIsKey{}).(*apitype.WhoIsResponse) + if who.Node.IsTagged() { + // Use the nodes FQDN as the username, and the nodes tags as the groups. + // "Impersonate-Group" requires "Impersonate-User" to be set. + r.Header.Set("Impersonate-User", strings.TrimSuffix(who.Node.Name, ".")) + for _, tag := range who.Node.Tags { + r.Header.Add("Impersonate-Group", tag) + } + } else { + r.Header.Set("Impersonate-User", who.UserProfile.LoginName) + } + // Replace the URL with the Kubernetes APIServer. r.URL.Scheme = u.Scheme r.URL.Host = u.Host @@ -72,9 +104,17 @@ func runAuthProxy(lc *tailscale.LocalClient, ls net.Listener, rt http.RoundTripp Transport: rt, }, } - if err := http.Serve(tls.NewListener(ls, &tls.Config{ - GetCertificate: lc.GetCertificate, - }), ap); err != nil { + hs := &http.Server{ + // Kubernetes uses SPDY for exec and port-forward, however SPDY is + // incompatible with HTTP/2; so disable HTTP/2 in the proxy. + TLSConfig: &tls.Config{ + GetCertificate: lc.GetCertificate, + NextProtos: []string{"http/1.1"}, + }, + TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), + Handler: ap, + } + if err := hs.ServeTLS(ln, "", ""); err != nil { log.Fatalf("runAuthProxy: failed to serve %v", err) } } diff --git a/cmd/mkmanifest/main.go b/cmd/mkmanifest/main.go index 7209fe3b71003..fb3c729f12d21 100644 --- a/cmd/mkmanifest/main.go +++ b/cmd/mkmanifest/main.go @@ -19,7 +19,7 @@ func main() { arch := winres.Arch(os.Args[1]) switch arch { - case winres.ArchAMD64, winres.ArchARM64, winres.ArchI386, winres.ArchARM: + case winres.ArchAMD64, winres.ArchARM64, winres.ArchI386: default: log.Fatalf("unsupported arch: %s", arch) } diff --git a/cmd/mkversion/mkversion.go b/cmd/mkversion/mkversion.go new file mode 100644 index 0000000000000..c8c8bf17930f6 --- /dev/null +++ b/cmd/mkversion/mkversion.go @@ -0,0 +1,44 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// mkversion gets version info from git and outputs a bunch of shell variables +// that get used elsewhere in the build system to embed version numbers into +// binaries. +package main + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "time" + + "tailscale.com/tailcfg" + "tailscale.com/version/mkversion" +) + +func main() { + prefix := "" + if len(os.Args) > 1 { + if os.Args[1] == "--export" { + prefix = "export " + } else { + fmt.Println("usage: mkversion [--export|-h|--help]") + os.Exit(1) + } + } + + var b bytes.Buffer + io.WriteString(&b, mkversion.Info().String()) + // Copyright and the client capability are not part of the version + // information, but similarly used in Xcode builds to embed in the metadata, + // thus generate them now. + copyright := fmt.Sprintf("Copyright © %d Tailscale Inc. All Rights Reserved.", time.Now().Year()) + fmt.Fprintf(&b, "VERSION_COPYRIGHT=%q\n", copyright) + fmt.Fprintf(&b, "VERSION_CAPABILITY=%d\n", tailcfg.CurrentCapabilityVersion) + s := bufio.NewScanner(&b) + for s.Scan() { + fmt.Println(prefix + s.Text()) + } +} diff --git a/cmd/netlogfmt/main.go b/cmd/netlogfmt/main.go index 7728c94a0cd72..0d1b1667ef9cc 100644 --- a/cmd/netlogfmt/main.go +++ b/cmd/netlogfmt/main.go @@ -43,7 +43,7 @@ import ( jsonv2 "github.com/go-json-experiment/json" "golang.org/x/exp/maps" "golang.org/x/exp/slices" - "tailscale.com/logtail" + "tailscale.com/types/logid" "tailscale.com/types/netlogtype" "tailscale.com/util/must" ) @@ -136,8 +136,8 @@ func processObject(dec *jsonv2.Decoder) { type message struct { Logtail struct { - ID logtail.PublicID `json:"id"` - Logged time.Time `json:"server_time"` + ID logid.PublicID `json:"id"` + Logged time.Time `json:"server_time"` } `json:"logtail"` Logged time.Time `json:"logged"` netlogtype.Message diff --git a/cmd/nginx-auth/mkdeb.sh b/cmd/nginx-auth/mkdeb.sh index 5547d0269b3ee..59f43230d0817 100755 --- a/cmd/nginx-auth/mkdeb.sh +++ b/cmd/nginx-auth/mkdeb.sh @@ -2,30 +2,31 @@ set -e -CGO_ENABLED=0 GOARCH=amd64 GOOS=linux go build -o tailscale.nginx-auth . +VERSION=0.1.3 +for ARCH in amd64 arm64; do + CGO_ENABLED=0 GOARCH=${ARCH} GOOS=linux go build -o tailscale.nginx-auth . -VERSION=0.1.2 + mkpkg \ + --out=tailscale-nginx-auth-${VERSION}-${ARCH}.deb \ + --name=tailscale-nginx-auth \ + --version=${VERSION} \ + --type=deb \ + --arch=${ARCH} \ + --postinst=deb/postinst.sh \ + --postrm=deb/postrm.sh \ + --prerm=deb/prerm.sh \ + --description="Tailscale NGINX authentication protocol handler" \ + --files=./tailscale.nginx-auth:/usr/sbin/tailscale.nginx-auth,./tailscale.nginx-auth.socket:/lib/systemd/system/tailscale.nginx-auth.socket,./tailscale.nginx-auth.service:/lib/systemd/system/tailscale.nginx-auth.service,./README.md:/usr/share/tailscale/nginx-auth/README.md -mkpkg \ - --out=tailscale-nginx-auth-${VERSION}-amd64.deb \ - --name=tailscale-nginx-auth \ - --version=${VERSION} \ - --type=deb \ - --arch=amd64 \ - --postinst=deb/postinst.sh \ - --postrm=deb/postrm.sh \ - --prerm=deb/prerm.sh \ - --description="Tailscale NGINX authentication protocol handler" \ - --files=./tailscale.nginx-auth:/usr/sbin/tailscale.nginx-auth,./tailscale.nginx-auth.socket:/lib/systemd/system/tailscale.nginx-auth.socket,./tailscale.nginx-auth.service:/lib/systemd/system/tailscale.nginx-auth.service,./README.md:/usr/share/tailscale/nginx-auth/README.md - -mkpkg \ - --out=tailscale-nginx-auth-${VERSION}-amd64.rpm \ - --name=tailscale-nginx-auth \ - --version=${VERSION} \ - --type=rpm \ - --arch=amd64 \ - --postinst=rpm/postinst.sh \ - --postrm=rpm/postrm.sh \ - --prerm=rpm/prerm.sh \ - --description="Tailscale NGINX authentication protocol handler" \ - --files=./tailscale.nginx-auth:/usr/sbin/tailscale.nginx-auth,./tailscale.nginx-auth.socket:/lib/systemd/system/tailscale.nginx-auth.socket,./tailscale.nginx-auth.service:/lib/systemd/system/tailscale.nginx-auth.service,./README.md:/usr/share/tailscale/nginx-auth/README.md + mkpkg \ + --out=tailscale-nginx-auth-${VERSION}-${ARCH}.rpm \ + --name=tailscale-nginx-auth \ + --version=${VERSION} \ + --type=rpm \ + --arch=${ARCH} \ + --postinst=rpm/postinst.sh \ + --postrm=rpm/postrm.sh \ + --prerm=rpm/prerm.sh \ + --description="Tailscale NGINX authentication protocol handler" \ + --files=./tailscale.nginx-auth:/usr/sbin/tailscale.nginx-auth,./tailscale.nginx-auth.socket:/lib/systemd/system/tailscale.nginx-auth.socket,./tailscale.nginx-auth.service:/lib/systemd/system/tailscale.nginx-auth.service,./README.md:/usr/share/tailscale/nginx-auth/README.md +done diff --git a/cmd/nginx-auth/nginx-auth.go b/cmd/nginx-auth/nginx-auth.go index 796d794c0687f..09da74da1d3c8 100644 --- a/cmd/nginx-auth/nginx-auth.go +++ b/cmd/nginx-auth/nginx-auth.go @@ -56,7 +56,7 @@ func main() { return } - if len(info.Node.Tags) != 0 { + if info.Node.IsTagged() { w.WriteHeader(http.StatusForbidden) log.Printf("node %s is tagged", info.Node.Hostinfo.Hostname()) return diff --git a/cmd/pgproxy/pgproxy.go b/cmd/pgproxy/pgproxy.go index 54f80a674a315..a566133169cd4 100644 --- a/cmd/pgproxy/pgproxy.go +++ b/cmd/pgproxy/pgproxy.go @@ -272,7 +272,7 @@ func (p *proxy) serve(sessionID int64, c net.Conn) error { } if buf[0] != 'S' { p.errors.Add("upstream-bad-protocol", 1) - return fmt.Errorf("upstream didn't acknowldge start-ssl, said %q", buf[0]) + return fmt.Errorf("upstream didn't acknowledge start-ssl, said %q", buf[0]) } tlsConf := &tls.Config{ ServerName: p.upstreamHost, diff --git a/cmd/printdep/printdep.go b/cmd/printdep/printdep.go index 7d209489a3c22..044283209c08c 100644 --- a/cmd/printdep/printdep.go +++ b/cmd/printdep/printdep.go @@ -31,20 +31,11 @@ func main() { fmt.Println(strings.TrimSpace(ts.GoToolchainRev)) } if *goToolchainURL { - var suffix string - switch runtime.GOARCH { - case "amd64": - // None - case "arm64": - suffix = "-" + runtime.GOARCH - default: - log.Fatalf("unsupported GOARCH %q", runtime.GOARCH) - } switch runtime.GOOS { case "linux", "darwin": default: log.Fatalf("unsupported GOOS %q", runtime.GOOS) } - fmt.Printf("https://github.com/tailscale/go/releases/download/build-%s/%s%s.tar.gz\n", strings.TrimSpace(ts.GoToolchainRev), runtime.GOOS, suffix) + fmt.Printf("https://github.com/tailscale/go/releases/download/build-%s/%s-%s.tar.gz\n", strings.TrimSpace(ts.GoToolchainRev), runtime.GOOS, runtime.GOARCH) } } diff --git a/cmd/proxy-to-grafana/proxy-to-grafana.go b/cmd/proxy-to-grafana/proxy-to-grafana.go index 33800d583b0b5..b5b67ee80e752 100644 --- a/cmd/proxy-to-grafana/proxy-to-grafana.go +++ b/cmd/proxy-to-grafana/proxy-to-grafana.go @@ -147,7 +147,7 @@ func getTailscaleUser(ctx context.Context, localClient *tailscale.LocalClient, i if err != nil { return nil, fmt.Errorf("failed to identify remote host: %w", err) } - if len(whois.Node.Tags) != 0 { + if whois.Node.IsTagged() { return nil, fmt.Errorf("tagged nodes are not users") } if whois.UserProfile == nil || whois.UserProfile.LoginName == "" { diff --git a/cmd/sniproxy/snipproxy.go b/cmd/sniproxy/snipproxy.go new file mode 100644 index 0000000000000..068c107c9626d --- /dev/null +++ b/cmd/sniproxy/snipproxy.go @@ -0,0 +1,219 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// The sniproxy is an outbound SNI proxy. It receives TLS connections over +// Tailscale on one or more TCP ports and sends them out to the same SNI +// hostname & port on the internet. It only does TCP. +package main + +import ( + "context" + "flag" + "log" + "net" + "net/http" + "strings" + "time" + + "golang.org/x/net/dns/dnsmessage" + "inet.af/tcpproxy" + "tailscale.com/client/tailscale" + "tailscale.com/net/netutil" + "tailscale.com/tsnet" + "tailscale.com/types/nettype" +) + +var ( + ports = flag.String("ports", "443", "comma-separated list of ports to proxy") + promoteHTTPS = flag.Bool("promote-https", true, "promote HTTP to HTTPS") +) + +var tsMBox = dnsmessage.MustNewName("support.tailscale.com.") + +func main() { + flag.Parse() + if *ports == "" { + log.Fatal("no ports") + } + + var s server + defer s.ts.Close() + + lc, err := s.ts.LocalClient() + if err != nil { + log.Fatal(err) + } + s.lc = lc + + for _, portStr := range strings.Split(*ports, ",") { + ln, err := s.ts.Listen("tcp", ":"+portStr) + if err != nil { + log.Fatal(err) + } + log.Printf("Serving on port %v ...", portStr) + go s.serve(ln) + } + + ln, err := s.ts.Listen("udp", ":53") + if err != nil { + log.Fatal(err) + } + go s.serveDNS(ln) + + if *promoteHTTPS { + ln, err := s.ts.Listen("tcp", ":80") + if err != nil { + log.Fatal(err) + } + log.Printf("Promoting HTTP to HTTPS ...") + go s.promoteHTTPS(ln) + } + + select {} +} + +type server struct { + ts tsnet.Server + lc *tailscale.LocalClient +} + +func (s *server) serve(ln net.Listener) { + for { + c, err := ln.Accept() + if err != nil { + log.Fatal(err) + } + go s.serveConn(c) + } +} + +func (s *server) serveDNS(ln net.Listener) { + for { + c, err := ln.Accept() + if err != nil { + log.Fatal(err) + } + go s.serveDNSConn(c.(nettype.ConnPacketConn)) + } +} + +func (s *server) serveDNSConn(c nettype.ConnPacketConn) { + defer c.Close() + c.SetReadDeadline(time.Now().Add(5 * time.Second)) + buf := make([]byte, 1500) + n, err := c.Read(buf) + if err != nil { + log.Printf("c.Read failed: %v\n ", err) + return + } + + var msg dnsmessage.Message + err = msg.Unpack(buf[:n]) + if err != nil { + log.Printf("dnsmessage unpack failed: %v\n ", err) + return + } + + buf, err = s.dnsResponse(&msg) + if err != nil { + log.Printf("s.dnsResponse failed: %v\n", err) + return + } + + _, err = c.Write(buf) + if err != nil { + log.Printf("c.Write failed: %v\n", err) + return + } +} + +func (s *server) serveConn(c net.Conn) { + addrPortStr := c.LocalAddr().String() + _, port, err := net.SplitHostPort(addrPortStr) + if err != nil { + log.Printf("bogus addrPort %q", addrPortStr) + c.Close() + return + } + + var dialer net.Dialer + dialer.Timeout = 5 * time.Second + + var p tcpproxy.Proxy + p.ListenFunc = func(net, laddr string) (net.Listener, error) { + return netutil.NewOneConnListener(c, nil), nil + } + p.AddSNIRouteFunc(addrPortStr, func(ctx context.Context, sniName string) (t tcpproxy.Target, ok bool) { + return &tcpproxy.DialProxy{ + Addr: net.JoinHostPort(sniName, port), + DialContext: dialer.DialContext, + }, true + }) + p.Start() +} + +func (s *server) dnsResponse(req *dnsmessage.Message) (buf []byte, err error) { + resp := dnsmessage.NewBuilder(buf, + dnsmessage.Header{ + ID: req.Header.ID, + Response: true, + Authoritative: true, + }) + resp.EnableCompression() + + if len(req.Questions) == 0 { + buf, _ = resp.Finish() + return + } + + q := req.Questions[0] + err = resp.StartQuestions() + if err != nil { + return + } + resp.Question(q) + + ip4, ip6 := s.ts.TailscaleIPs() + err = resp.StartAnswers() + if err != nil { + return + } + + switch q.Type { + case dnsmessage.TypeAAAA: + err = resp.AAAAResource( + dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120}, + dnsmessage.AAAAResource{AAAA: ip6.As16()}, + ) + + case dnsmessage.TypeA: + err = resp.AResource( + dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120}, + dnsmessage.AResource{A: ip4.As4()}, + ) + case dnsmessage.TypeSOA: + err = resp.SOAResource( + dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120}, + dnsmessage.SOAResource{NS: q.Name, MBox: tsMBox, Serial: 2023030600, + Refresh: 120, Retry: 120, Expire: 120, MinTTL: 60}, + ) + case dnsmessage.TypeNS: + err = resp.NSResource( + dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120}, + dnsmessage.NSResource{NS: tsMBox}, + ) + } + + if err != nil { + return + } + + return resp.Finish() +} + +func (s *server) promoteHTTPS(ln net.Listener) { + err := http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "https://"+r.Host+r.RequestURI, http.StatusFound) + })) + log.Fatalf("promoteHTTPS http.Serve: %v", err) +} diff --git a/cmd/sync-containers/main.go b/cmd/sync-containers/main.go index f58d8e0ea6228..94864ff274062 100644 --- a/cmd/sync-containers/main.go +++ b/cmd/sync-containers/main.go @@ -85,6 +85,15 @@ func main() { log.Printf("%d tags to remove: %s\n", len(remove), strings.Join(remove, ", ")) log.Printf("Not removing any tags for safety.\n") } + + var wellKnown = [...]string{"latest", "stable"} + for _, tag := range wellKnown { + if needsUpdate(*src, *dst, tag) { + if err := copyTag(*src, *dst, tag, opts...); err != nil { + log.Printf("Updating tag %q: progress error: %v", tag, err) + } + } + } } func copyTag(srcStr, dstStr, tag string, opts ...remote.Option) error { @@ -178,3 +187,26 @@ func diffTags(src, dst []string) (add, remove []string) { sort.Strings(remove) return add, remove } + +func needsUpdate(srcStr, dstStr, tag string) bool { + src, err := name.ParseReference(fmt.Sprintf("%s:%s", srcStr, tag)) + if err != nil { + return false + } + dst, err := name.ParseReference(fmt.Sprintf("%s:%s", dstStr, tag)) + if err != nil { + return false + } + + srcDesc, err := remote.Get(src) + if err != nil { + return false + } + + dstDesc, err := remote.Get(dst) + if err != nil { + return true + } + + return srcDesc.Digest != dstDesc.Digest +} diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index e461ca56b44e3..ce41e5965f4b5 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -113,12 +113,15 @@ change in the future. loginCmd, logoutCmd, switchCmd, + configureCmd, netcheckCmd, ipCmd, statusCmd, pingCmd, ncCmd, sshCmd, + funnelCmd, + serveCmd, versionCmd, webCmd, fileCmd, @@ -146,8 +149,6 @@ change in the future. switch { case slices.Contains(args, "debug"): rootCmd.Subcommands = append(rootCmd.Subcommands, debugCmd) - case slices.Contains(args, "serve"): - rootCmd.Subcommands = append(rootCmd.Subcommands, serveCmd) case slices.Contains(args, "update"): rootCmd.Subcommands = append(rootCmd.Subcommands, updateCmd) } diff --git a/cmd/tailscale/cli/cli_test.go b/cmd/tailscale/cli/cli_test.go index 623cb2c34f531..309e7ae03df7c 100644 --- a/cmd/tailscale/cli/cli_test.go +++ b/cmd/tailscale/cli/cli_test.go @@ -621,9 +621,16 @@ func TestPrefsFromUpArgs(t *testing.T) { { name: "error_long_hostname", args: upArgsT{ - hostname: strings.Repeat("a", 300), + hostname: strings.Repeat(strings.Repeat("a", 63)+".", 4), }, - wantErr: `hostname too long: 300 bytes (max 256)`, + wantErr: `"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" is too long to be a DNS name`, + }, + { + name: "error_long_label", + args: upArgsT{ + hostname: strings.Repeat("a", 64) + ".example.com", + }, + wantErr: `"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" is not a valid DNS label`, }, { name: "error_linux_netfilter_empty", @@ -1071,13 +1078,42 @@ func TestUpdatePrefs(t *testing.T) { }, env: upCheckEnv{backendState: "Running"}, }, + { + name: "force_reauth_over_ssh_no_risk", + flags: []string{"--force-reauth"}, + sshOverTailscale: true, + curPrefs: &ipn.Prefs{ + ControlURL: "https://login.tailscale.com", + AllowSingleHosts: true, + CorpDNS: true, + NetfilterMode: preftype.NetfilterOn, + }, + env: upCheckEnv{backendState: "Running"}, + wantErrSubtr: "aborted, no changes made", + }, + { + name: "force_reauth_over_ssh", + flags: []string{"--force-reauth", "--accept-risk=lose-ssh"}, + sshOverTailscale: true, + curPrefs: &ipn.Prefs{ + ControlURL: "https://login.tailscale.com", + AllowSingleHosts: true, + CorpDNS: true, + NetfilterMode: preftype.NetfilterOn, + }, + wantJustEditMP: nil, + env: upCheckEnv{backendState: "Running"}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.sshOverTailscale { - old := getSSHClientEnvVar - getSSHClientEnvVar = func() string { return "100.100.100.100 1 1" } - t.Cleanup(func() { getSSHClientEnvVar = old }) + tstest.Replace(t, &getSSHClientEnvVar, func() string { return "100.100.100.100 1 1" }) + } else if isSSHOverTailscale() { + // The test is being executed over a "real" tailscale SSH + // session, but sshOverTailscale is unset. Make the test appear + // as if it's not over tailscale SSH. + tstest.Replace(t, &getSSHClientEnvVar, func() string { return "" }) } if tt.env.goos == "" { tt.env.goos = "linux" diff --git a/cmd/tailscale/cli/configure-kube.go b/cmd/tailscale/cli/configure-kube.go new file mode 100644 index 0000000000000..0c7371927db9e --- /dev/null +++ b/cmd/tailscale/cli/configure-kube.go @@ -0,0 +1,184 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause +//go:build !ts_omit_kube + +package cli + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/peterbourgon/ff/v3/ffcli" + "golang.org/x/exp/slices" + "k8s.io/client-go/util/homedir" + "sigs.k8s.io/yaml" + "tailscale.com/version" +) + +func init() { + configureCmd.Subcommands = append(configureCmd.Subcommands, configureKubeconfigCmd) +} + +var configureKubeconfigCmd = &ffcli.Command{ + Name: "kubeconfig", + ShortHelp: "[ALPHA] Connect to a Kubernetes cluster using a Tailscale Auth Proxy", + ShortUsage: "kubeconfig ", + LongHelp: strings.TrimSpace(` +Run this command to configure kubectl to connect to a Kubernetes cluster over Tailscale. + +The hostname argument should be set to the Tailscale hostname of the peer running as an auth proxy in the cluster. + +See: https://tailscale.com/s/k8s-auth-proxy +`), + FlagSet: (func() *flag.FlagSet { + fs := newFlagSet("kubeconfig") + return fs + })(), + Exec: runConfigureKubeconfig, +} + +// kubeconfigPath returns the path to the kubeconfig file for the current user. +func kubeconfigPath() string { + var dir string + if version.IsSandboxedMacOS() { + // The HOME environment variable in macOS sandboxed apps is set to + // ~/Library/Containers//Data, but the kubeconfig file is + // located in ~/.kube/config. We rely on the "com.apple.security.temporary-exception.files.home-relative-path.read-write" + // entitlement to access the file. + containerHome := os.Getenv("HOME") + dir, _, _ = strings.Cut(containerHome, "/Library/Containers/") + } else { + dir = homedir.HomeDir() + } + return filepath.Join(dir, ".kube", "config") +} + +func runConfigureKubeconfig(ctx context.Context, args []string) error { + if len(args) != 1 { + return errors.New("unknown arguments") + } + hostOrFQDN := args[0] + + st, err := localClient.Status(ctx) + if err != nil { + return err + } + if st.BackendState != "Running" { + return errors.New("Tailscale is not running") + } + targetFQDN, ok := nodeDNSNameFromArg(st, hostOrFQDN) + if !ok { + return fmt.Errorf("no peer found with hostname %q", hostOrFQDN) + } + targetFQDN = strings.TrimSuffix(targetFQDN, ".") + if err := setKubeconfigForPeer(targetFQDN, kubeconfigPath()); err != nil { + return err + } + printf("kubeconfig configured for %q\n", hostOrFQDN) + return nil +} + +// appendOrSetNamed finds a map with a "name" key matching name in dst, and +// replaces it with val. If no such map is found, val is appended to dst. +func appendOrSetNamed(dst []any, name string, val map[string]any) []any { + if got := slices.IndexFunc(dst, func(m any) bool { + if m, ok := m.(map[string]any); ok { + return m["name"] == name + } + return false + }); got != -1 { + dst[got] = val + } else { + dst = append(dst, val) + } + return dst +} + +var errInvalidKubeconfig = errors.New("invalid kubeconfig") + +func updateKubeconfig(cfgYaml []byte, fqdn string) ([]byte, error) { + var cfg map[string]any + if len(cfgYaml) > 0 { + if err := yaml.Unmarshal(cfgYaml, &cfg); err != nil { + return nil, errInvalidKubeconfig + } + } + if cfg == nil { + cfg = map[string]any{ + "apiVersion": "v1", + "kind": "Config", + } + } else if cfg["apiVersion"] != "v1" || cfg["kind"] != "Config" { + return nil, errInvalidKubeconfig + } + + var clusters []any + if cm, ok := cfg["clusters"]; ok { + clusters = cm.([]any) + } + cfg["clusters"] = appendOrSetNamed(clusters, fqdn, map[string]any{ + "name": fqdn, + "cluster": map[string]string{ + "server": "https://" + fqdn, + }, + }) + + var users []any + if um, ok := cfg["users"]; ok { + users = um.([]any) + } + cfg["users"] = appendOrSetNamed(users, "tailscale-auth", map[string]any{ + // We just need one of these, and can reuse it for all clusters. + "name": "tailscale-auth", + "user": map[string]string{ + // We do not use the token, but if we do not set anything here + // kubectl will prompt for a username and password. + "token": "unused", + }, + }) + + var contexts []any + if cm, ok := cfg["contexts"]; ok { + contexts = cm.([]any) + } + cfg["contexts"] = appendOrSetNamed(contexts, fqdn, map[string]any{ + "name": fqdn, + "context": map[string]string{ + "cluster": fqdn, + "user": "tailscale-auth", + }, + }) + cfg["current-context"] = fqdn + return yaml.Marshal(cfg) +} + +func setKubeconfigForPeer(fqdn, filePath string) error { + dir := filepath.Dir(filePath) + if _, err := os.Stat(dir); err != nil { + if !os.IsNotExist(err) { + return err + } + if err := os.Mkdir(dir, 0755); err != nil { + if version.IsSandboxedMacOS() && errors.Is(err, os.ErrPermission) { + // macOS sandboxing prevents us from creating the .kube directory + // in the home directory. + return errors.New("unable to create .kube directory in home directory, please create it manually (e.g. mkdir ~/.kube") + } + return err + } + } + b, err := os.ReadFile(filePath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("reading kubeconfig: %w", err) + } + b, err = updateKubeconfig(b, fqdn) + if err != nil { + return err + } + return os.WriteFile(filePath, b, 0600) +} diff --git a/cmd/tailscale/cli/configure-kube_test.go b/cmd/tailscale/cli/configure-kube_test.go new file mode 100644 index 0000000000000..0f326cd64a7b3 --- /dev/null +++ b/cmd/tailscale/cli/configure-kube_test.go @@ -0,0 +1,196 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause +//go:build !ts_omit_kube + +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestKubeconfig(t *testing.T) { + const fqdn = "foo.tail-scale.ts.net" + tests := []struct { + name string + in string + want string + wantErr error + }{ + { + name: "invalid-yaml", + in: `apiVersion: v1 +kind: ,asdf`, + wantErr: errInvalidKubeconfig, + }, + { + name: "invalid-cfg", + in: `apiVersion: v1 +kind: Pod`, + wantErr: errInvalidKubeconfig, + }, + { + name: "empty", + in: "", + want: `apiVersion: v1 +clusters: +- cluster: + server: https://foo.tail-scale.ts.net + name: foo.tail-scale.ts.net +contexts: +- context: + cluster: foo.tail-scale.ts.net + user: tailscale-auth + name: foo.tail-scale.ts.net +current-context: foo.tail-scale.ts.net +kind: Config +users: +- name: tailscale-auth + user: + token: unused`, + }, + { + name: "already-configured", + in: `apiVersion: v1 +clusters: +- cluster: + server: https://foo.tail-scale.ts.net + name: foo.tail-scale.ts.net +contexts: +- context: + cluster: foo.tail-scale.ts.net + user: tailscale-auth + name: foo.tail-scale.ts.net +kind: Config +current-context: foo.tail-scale.ts.net +users: +- name: tailscale-auth + user: + token: unused`, + want: `apiVersion: v1 +clusters: +- cluster: + server: https://foo.tail-scale.ts.net + name: foo.tail-scale.ts.net +contexts: +- context: + cluster: foo.tail-scale.ts.net + user: tailscale-auth + name: foo.tail-scale.ts.net +current-context: foo.tail-scale.ts.net +kind: Config +users: +- name: tailscale-auth + user: + token: unused`, + }, + { + name: "other-cluster", + in: `apiVersion: v1 +clusters: +- cluster: + server: https://192.168.1.1:8443 + name: some-cluster +contexts: +- context: + cluster: some-cluster + user: some-auth + name: some-cluster +kind: Config +current-context: some-cluster +users: +- name: some-auth + user: + token: asdfasdf`, + want: `apiVersion: v1 +clusters: +- cluster: + server: https://192.168.1.1:8443 + name: some-cluster +- cluster: + server: https://foo.tail-scale.ts.net + name: foo.tail-scale.ts.net +contexts: +- context: + cluster: some-cluster + user: some-auth + name: some-cluster +- context: + cluster: foo.tail-scale.ts.net + user: tailscale-auth + name: foo.tail-scale.ts.net +current-context: foo.tail-scale.ts.net +kind: Config +users: +- name: some-auth + user: + token: asdfasdf +- name: tailscale-auth + user: + token: unused`, + }, + { + name: "already-using-tailscale", + in: `apiVersion: v1 +clusters: +- cluster: + server: https://bar.tail-scale.ts.net + name: bar.tail-scale.ts.net +contexts: +- context: + cluster: bar.tail-scale.ts.net + user: tailscale-auth + name: bar.tail-scale.ts.net +kind: Config +current-context: bar.tail-scale.ts.net +users: +- name: tailscale-auth + user: + token: unused`, + want: `apiVersion: v1 +clusters: +- cluster: + server: https://bar.tail-scale.ts.net + name: bar.tail-scale.ts.net +- cluster: + server: https://foo.tail-scale.ts.net + name: foo.tail-scale.ts.net +contexts: +- context: + cluster: bar.tail-scale.ts.net + user: tailscale-auth + name: bar.tail-scale.ts.net +- context: + cluster: foo.tail-scale.ts.net + user: tailscale-auth + name: foo.tail-scale.ts.net +current-context: foo.tail-scale.ts.net +kind: Config +users: +- name: tailscale-auth + user: + token: unused`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := updateKubeconfig([]byte(tt.in), fqdn) + if err != nil { + if err != tt.wantErr { + t.Fatalf("updateKubeconfig() error = %v, wantErr %v", err, tt.wantErr) + } + return + } else if tt.wantErr != nil { + t.Fatalf("updateKubeconfig() error = %v, wantErr %v", err, tt.wantErr) + } + got = bytes.TrimSpace(got) + want := []byte(strings.TrimSpace(tt.want)) + if d := cmp.Diff(want, got); d != "" { + t.Errorf("Kubeconfig() mismatch (-want +got):\n%s", d) + } + }) + } +} diff --git a/cmd/tailscale/cli/configure-host.go b/cmd/tailscale/cli/configure-synology.go similarity index 72% rename from cmd/tailscale/cli/configure-host.go rename to cmd/tailscale/cli/configure-synology.go index 7aeaa477a187e..b44828d017405 100644 --- a/cmd/tailscale/cli/configure-host.go +++ b/cmd/tailscale/cli/configure-synology.go @@ -18,26 +18,38 @@ import ( "tailscale.com/version/distro" ) +// configureHostCmd is the "tailscale configure-host" command which was once +// used to configure Synology devices, but is now a compatibility alias to +// "tailscale configure synology". var configureHostCmd = &ffcli.Command{ Name: "configure-host", - Exec: runConfigureHost, - ShortHelp: "Configure Synology to enable more Tailscale features", + Exec: runConfigureSynology, + ShortHelp: synologyConfigureCmd.ShortHelp, + LongHelp: synologyConfigureCmd.LongHelp, + FlagSet: (func() *flag.FlagSet { + fs := newFlagSet("configure-host") + return fs + })(), +} + +var synologyConfigureCmd = &ffcli.Command{ + Name: "synology", + Exec: runConfigureSynology, + ShortHelp: "Configure Synology to enable outbound connections", LongHelp: strings.TrimSpace(` -The 'configure-host' command is intended to run at boot as root -to create the /dev/net/tun device and give the tailscaled binary -permission to use it. +This command is intended to run at boot as root on a Synology device to +create the /dev/net/tun device and give the tailscaled binary permission +to use it. -See: https://tailscale.com/kb/1152/synology-outbound/ +See: https://tailscale.com/s/synology-outbound `), FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("configure-host") + fs := newFlagSet("synology") return fs })(), } -var configureHostArgs struct{} - -func runConfigureHost(ctx context.Context, args []string) error { +func runConfigureSynology(ctx context.Context, args []string) error { if len(args) > 0 { return errors.New("unknown arguments") } diff --git a/cmd/tailscale/cli/configure.go b/cmd/tailscale/cli/configure.go new file mode 100644 index 0000000000000..2ebed0503a62d --- /dev/null +++ b/cmd/tailscale/cli/configure.go @@ -0,0 +1,38 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package cli + +import ( + "context" + "flag" + "runtime" + "strings" + + "github.com/peterbourgon/ff/v3/ffcli" + "tailscale.com/version/distro" +) + +var configureCmd = &ffcli.Command{ + Name: "configure", + ShortHelp: "[ALPHA] Configure the host to enable more Tailscale features", + LongHelp: strings.TrimSpace(` +The 'configure' set of commands are intended to provide a way to enable different +services on the host to use Tailscale in more ways. +`), + FlagSet: (func() *flag.FlagSet { + fs := newFlagSet("configure") + return fs + })(), + Subcommands: configureSubcommands(), + Exec: func(ctx context.Context, args []string) error { + return flag.ErrHelp + }, +} + +func configureSubcommands() (out []*ffcli.Command) { + if runtime.GOOS == "linux" && distro.Get() == distro.Synology { + out = append(out, synologyConfigureCmd) + } + return out +} diff --git a/cmd/tailscale/cli/debug.go b/cmd/tailscale/cli/debug.go index 1643e573f81b4..851ab5a93daad 100644 --- a/cmd/tailscale/cli/debug.go +++ b/cmd/tailscale/cli/debug.go @@ -201,6 +201,23 @@ var debugCmd = &ffcli.Command{ return fs })(), }, + { + Name: "portmap", + Exec: debugPortmap, + ShortHelp: "run portmap debugging debugging", + FlagSet: (func() *flag.FlagSet { + fs := newFlagSet("portmap") + fs.DurationVar(&debugPortmapArgs.duration, "duration", 5*time.Second, "timeout for port mapping") + fs.StringVar(&debugPortmapArgs.ty, "type", "", `portmap debug type (one of "", "pmp", "pcp", or "upnp")`) + fs.StringVar(&debugPortmapArgs.gwSelf, "gw-self", "", `override gateway and self IP (format: "gatewayIP/selfIP")`) + return fs + })(), + }, + { + Name: "peer-endpoint-changes", + Exec: runPeerEndpointChanges, + ShortHelp: "prints debug information about a peer's endpoint changes", + }, }, } @@ -789,3 +806,82 @@ func runCapture(ctx context.Context, args []string) error { _, err = io.Copy(f, stream) return err } + +var debugPortmapArgs struct { + duration time.Duration + gwSelf string + ty string +} + +func debugPortmap(ctx context.Context, args []string) error { + rc, err := localClient.DebugPortmap(ctx, + debugPortmapArgs.duration, + debugPortmapArgs.ty, + debugPortmapArgs.gwSelf, + ) + if err != nil { + return err + } + defer rc.Close() + + _, err = io.Copy(os.Stdout, rc) + return err +} + +func runPeerEndpointChanges(ctx context.Context, args []string) error { + st, err := localClient.Status(ctx) + if err != nil { + return fixTailscaledConnectError(err) + } + description, ok := isRunningOrStarting(st) + if !ok { + printf("%s\n", description) + os.Exit(1) + } + + if len(args) != 1 || args[0] == "" { + return errors.New("usage: peer-status ") + } + var ip string + + hostOrIP := args[0] + ip, self, err := tailscaleIPFromArg(ctx, hostOrIP) + if err != nil { + return err + } + if self { + printf("%v is local Tailscale IP\n", ip) + return nil + } + + if ip != hostOrIP { + log.Printf("lookup %q => %q", hostOrIP, ip) + } + + req, err := http.NewRequestWithContext(ctx, "GET", "http://local-tailscaled.sock/localapi/v0/debug-peer-endpoint-changes?ip="+ip, nil) + if err != nil { + return err + } + + resp, err := localClient.DoLocalRequest(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var dst bytes.Buffer + if err := json.Indent(&dst, body, "", " "); err != nil { + return fmt.Errorf("indenting returned JSON: %w", err) + } + + if ss := dst.String(); !strings.HasSuffix(ss, "\n") { + dst.WriteByte('\n') + } + fmt.Printf("%s", dst.String()) + return nil +} diff --git a/cmd/tailscale/cli/funnel.go b/cmd/tailscale/cli/funnel.go new file mode 100644 index 0000000000000..8e3e800041c7e --- /dev/null +++ b/cmd/tailscale/cli/funnel.go @@ -0,0 +1,138 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package cli + +import ( + "context" + "flag" + "fmt" + "net" + "os" + "strconv" + "strings" + + "github.com/peterbourgon/ff/v3/ffcli" + "tailscale.com/ipn" + "tailscale.com/util/mak" +) + +var funnelCmd = newFunnelCommand(&serveEnv{lc: &localClient}) + +// newFunnelCommand returns a new "funnel" subcommand using e as its environment. +// The funnel subcommand is used to turn on/off the Funnel service. +// Funnel is off by default. +// Funnel allows you to publish a 'tailscale serve' server publicly, open to the +// entire internet. +// newFunnelCommand shares the same serveEnv as the "serve" subcommand. See +// newServeCommand and serve.go for more details. +func newFunnelCommand(e *serveEnv) *ffcli.Command { + return &ffcli.Command{ + Name: "funnel", + ShortHelp: "Turn on/off Funnel service", + ShortUsage: strings.TrimSpace(` +funnel {on|off} + funnel status [--json] +`), + LongHelp: strings.Join([]string{ + "Funnel allows you to publish a 'tailscale serve'", + "server publicly, open to the entire internet.", + "", + "Turning off Funnel only turns off serving to the internet.", + "It does not affect serving to your tailnet.", + }, "\n"), + Exec: e.runFunnel, + UsageFunc: usageFunc, + Subcommands: []*ffcli.Command{ + { + Name: "status", + Exec: e.runServeStatus, + ShortHelp: "show current serve/funnel status", + FlagSet: e.newFlags("funnel-status", func(fs *flag.FlagSet) { + fs.BoolVar(&e.json, "json", false, "output JSON") + }), + UsageFunc: usageFunc, + }, + }, + } +} + +// runFunnel is the entry point for the "tailscale funnel" subcommand and +// manages turning on/off funnel. Funnel is off by default. +// +// Note: funnel is only supported on single DNS name for now. (2022-11-15) +func (e *serveEnv) runFunnel(ctx context.Context, args []string) error { + if len(args) != 2 { + return flag.ErrHelp + } + + var on bool + switch args[1] { + case "on", "off": + on = args[1] == "on" + default: + return flag.ErrHelp + } + sc, err := e.lc.GetServeConfig(ctx) + if err != nil { + return err + } + if sc == nil { + sc = new(ipn.ServeConfig) + } + st, err := e.getLocalClientStatus(ctx) + if err != nil { + return fmt.Errorf("getting client status: %w", err) + } + + port64, err := strconv.ParseUint(args[0], 10, 16) + if err != nil { + return err + } + port := uint16(port64) + + if err := ipn.CheckFunnelAccess(port, st.Self.Capabilities); err != nil { + return err + } + dnsName := strings.TrimSuffix(st.Self.DNSName, ".") + hp := ipn.HostPort(dnsName + ":" + strconv.Itoa(int(port))) + if on == sc.AllowFunnel[hp] { + printFunnelWarning(sc) + // Nothing to do. + return nil + } + if on { + mak.Set(&sc.AllowFunnel, hp, true) + } else { + delete(sc.AllowFunnel, hp) + // clear map mostly for testing + if len(sc.AllowFunnel) == 0 { + sc.AllowFunnel = nil + } + } + if err := e.lc.SetServeConfig(ctx, sc); err != nil { + return err + } + printFunnelWarning(sc) + return nil +} + +// printFunnelWarning prints a warning if the Funnel is on but there is no serve +// config for its host:port. +func printFunnelWarning(sc *ipn.ServeConfig) { + var warn bool + for hp, a := range sc.AllowFunnel { + if !a { + continue + } + _, portStr, _ := net.SplitHostPort(string(hp)) + p, _ := strconv.ParseUint(portStr, 10, 16) + if _, ok := sc.TCP[uint16(p)]; !ok { + warn = true + fmt.Fprintf(os.Stderr, "Warning: funnel=on for %s, but no serve config\n", hp) + } + } + if warn { + fmt.Fprintf(os.Stderr, " run: `tailscale serve --help` to see how to configure handlers\n") + } +} diff --git a/cmd/tailscale/cli/netcheck.go b/cmd/tailscale/cli/netcheck.go index 9023362d5f908..a1f79b1fe317f 100644 --- a/cmd/tailscale/cli/netcheck.go +++ b/cmd/tailscale/cli/netcheck.go @@ -47,7 +47,8 @@ var netcheckArgs struct { func runNetcheck(ctx context.Context, args []string) error { c := &netcheck.Client{ UDPBindAddr: envknob.String("TS_DEBUG_NETCHECK_UDP_BIND"), - PortMapper: portmapper.NewClient(logger.WithPrefix(log.Printf, "portmap: "), nil), + PortMapper: portmapper.NewClient(logger.WithPrefix(log.Printf, "portmap: "), nil, nil), + UseDNSCache: false, // always resolve, don't cache } if netcheckArgs.verbose { c.Logf = logger.WithPrefix(log.Printf, "netcheck: ") diff --git a/cmd/tailscale/cli/network-lock.go b/cmd/tailscale/cli/network-lock.go index 053aff3521d85..fa9fdad240fea 100644 --- a/cmd/tailscale/cli/network-lock.go +++ b/cmd/tailscale/cli/network-lock.go @@ -15,6 +15,7 @@ import ( "os" "strconv" "strings" + "time" "github.com/mattn/go-colorable" "github.com/mattn/go-isatty" @@ -40,7 +41,16 @@ var netlockCmd = &ffcli.Command{ nlLogCmd, nlLocalDisableCmd, }, - Exec: runNetworkLockStatus, + Exec: runNetworkLockNoSubcommand, +} + +func runNetworkLockNoSubcommand(ctx context.Context, args []string) error { + // Detect & handle the deprecated command 'lock tskey-wrap'. + if len(args) >= 2 && args[0] == "tskey-wrap" { + return runTskeyWrapCmd(ctx, args[1:]) + } + + return runNetworkLockStatus(ctx, args) } var nlInitArgs struct { @@ -230,6 +240,15 @@ func runNetworkLockStatus(ctx context.Context, args []string) error { if k.Key == st.PublicKey { line.WriteString("(self)") } + if k.Metadata["purpose"] == "pre-auth key" { + if preauthKeyID := k.Metadata["authkey_stableid"]; preauthKeyID != "" { + line.WriteString("(pre-auth key ") + line.WriteString(preauthKeyID) + line.WriteString(")") + } else { + line.WriteString("(pre-auth key)") + } + } fmt.Println(line.String()) } } @@ -245,11 +264,13 @@ func runNetworkLockStatus(ctx context.Context, args []string) error { for i, addr := range p.TailscaleIPs { line.WriteString(addr.String()) if i < len(p.TailscaleIPs)-1 { - line.WriteString(", ") + line.WriteString(",") } } line.WriteString("\t") line.WriteString(string(p.StableID)) + line.WriteString("\t") + line.WriteString(p.NodeKey.String()) fmt.Println(line.String()) } } @@ -267,14 +288,78 @@ var nlAddCmd = &ffcli.Command{ }, } +var nlRemoveArgs struct { + resign bool +} + var nlRemoveCmd = &ffcli.Command{ Name: "remove", - ShortUsage: "remove ...", + ShortUsage: "remove [--re-sign=false] ...", ShortHelp: "Removes one or more trusted signing keys from tailnet lock", LongHelp: "Removes one or more trusted signing keys from tailnet lock", - Exec: func(ctx context.Context, args []string) error { - return runNetworkLockModify(ctx, nil, args) - }, + Exec: runNetworkLockRemove, + FlagSet: (func() *flag.FlagSet { + fs := newFlagSet("lock remove") + fs.BoolVar(&nlRemoveArgs.resign, "re-sign", true, "resign signatures which would be invalidated by removal of trusted signing keys") + return fs + })(), +} + +func runNetworkLockRemove(ctx context.Context, args []string) error { + removeKeys, _, err := parseNLArgs(args, true, false) + if err != nil { + return err + } + st, err := localClient.NetworkLockStatus(ctx) + if err != nil { + return fixTailscaledConnectError(err) + } + if !st.Enabled { + return errors.New("tailnet lock is not enabled") + } + + if nlRemoveArgs.resign { + // Validate we are not removing trust in ourselves while resigning. This is because + // we resign with our own key, so the signatures would be immediately invalid. + for _, k := range removeKeys { + kID, err := k.ID() + if err != nil { + return fmt.Errorf("computing KeyID for key %v: %w", k, err) + } + if bytes.Equal(st.PublicKey.KeyID(), kID) { + return errors.New("cannot remove local trusted signing key while resigning; run command on a different node or with --re-sign=false") + } + } + + // Resign affected signatures for each of the keys we are removing. + for _, k := range removeKeys { + kID, _ := k.ID() // err already checked above + sigs, err := localClient.NetworkLockAffectedSigs(ctx, kID) + if err != nil { + return fmt.Errorf("affected sigs for key %X: %w", kID, err) + } + + for _, sigBytes := range sigs { + var sig tka.NodeKeySignature + if err := sig.Unserialize(sigBytes); err != nil { + return fmt.Errorf("failed decoding signature: %w", err) + } + var nodeKey key.NodePublic + if err := nodeKey.UnmarshalBinary(sig.Pubkey); err != nil { + return fmt.Errorf("failed decoding pubkey for signature: %w", err) + } + + // Safety: NetworkLockAffectedSigs() verifies all signatures before + // successfully returning. + rotationKey, _ := sig.UnverifiedWrappingPublic() + if err := localClient.NetworkLockSign(ctx, nodeKey, []byte(rotationKey)); err != nil { + return fmt.Errorf("failed to sign %v: %w", nodeKey, err) + } + } + } + } + + return localClient.NetworkLockModify(ctx, nil, removeKeys) } // parseNLArgs parses a slice of strings into slices of tka.Key & disablement @@ -350,13 +435,19 @@ func runNetworkLockModify(ctx context.Context, addArgs, removeArgs []string) err var nlSignCmd = &ffcli.Command{ Name: "sign", - ShortUsage: "sign []", - ShortHelp: "Signs a node key and transmits the signature to the coordination server", - LongHelp: "Signs a node key and transmits the signature to the coordination server", - Exec: runNetworkLockSign, + ShortUsage: "sign [] or sign ", + ShortHelp: "Signs a node or pre-approved auth key", + LongHelp: `Either: + - signs a node key and transmits the signature to the coordination server, or + - signs a pre-approved auth key, printing it in a form that can be used to bring up nodes under tailnet lock`, + Exec: runNetworkLockSign, } func runNetworkLockSign(ctx context.Context, args []string) error { + if len(args) > 0 && strings.HasPrefix(args[0], "tskey-auth-") { + return runTskeyWrapCmd(ctx, args) + } + var ( nodeKey key.NodePublic rotationKey key.NLPublic @@ -558,3 +649,56 @@ func runNetworkLockLog(ctx context.Context, args []string) error { } return nil } + +func runTskeyWrapCmd(ctx context.Context, args []string) error { + if len(args) != 1 { + return errors.New("usage: lock tskey-wrap ") + } + if strings.Contains(args[0], "--TL") { + return errors.New("Error: provided key was already wrapped") + } + + st, err := localClient.StatusWithoutPeers(ctx) + if err != nil { + return fixTailscaledConnectError(err) + } + + return wrapAuthKey(ctx, args[0], st) +} + +func wrapAuthKey(ctx context.Context, keyStr string, status *ipnstate.Status) error { + // Generate a separate tailnet-lock key just for the credential signature. + // We use the free-form meta strings to mark a little bit of metadata about this + // key. + priv := key.NewNLPrivate() + m := map[string]string{ + "purpose": "pre-auth key", + "wrapper_stableid": string(status.Self.ID), + "wrapper_createtime": fmt.Sprint(time.Now().Unix()), + } + if strings.HasPrefix(keyStr, "tskey-auth-") && strings.Index(keyStr[len("tskey-auth-"):], "-") > 0 { + // We don't want to accidentally embed the nonce part of the authkey in + // the event the format changes. As such, we make sure its in the format we + // expect (tskey-auth--nonce) before we parse + // out and embed the stableID. + s := strings.TrimPrefix(keyStr, "tskey-auth-") + m["authkey_stableid"] = s[:strings.Index(s, "-")] + } + k := tka.Key{ + Kind: tka.Key25519, + Public: priv.Public().Verifier(), + Votes: 1, + Meta: m, + } + + wrapped, err := localClient.NetworkLockWrapPreauthKey(ctx, keyStr, priv) + if err != nil { + return fmt.Errorf("wrapping failed: %w", err) + } + if err := localClient.NetworkLockModify(ctx, []tka.Key{k}, nil); err != nil { + return fmt.Errorf("add key failed: %w", err) + } + + fmt.Println(wrapped) + return nil +} diff --git a/cmd/tailscale/cli/serve.go b/cmd/tailscale/cli/serve.go index 01ddd73727712..7691c7497f2b7 100644 --- a/cmd/tailscale/cli/serve.go +++ b/cmd/tailscale/cli/serve.go @@ -21,10 +21,8 @@ import ( "strings" "github.com/peterbourgon/ff/v3/ffcli" - "golang.org/x/exp/slices" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" - "tailscale.com/tailcfg" "tailscale.com/util/mak" "tailscale.com/version" ) @@ -35,80 +33,59 @@ var serveCmd = newServeCommand(&serveEnv{lc: &localClient}) func newServeCommand(e *serveEnv) *ffcli.Command { return &ffcli.Command{ Name: "serve", - ShortHelp: "[ALPHA] Serve from your Tailscale node", + ShortHelp: "Serve content and local servers", ShortUsage: strings.TrimSpace(` - serve [flags] {proxy|path|text} - serve [flags] [sub-flags] `), +serve https: [off] + serve tcp: tcp://localhost: [off] + serve tls-terminated-tcp: tcp://localhost: [off] + serve status [--json] +`), LongHelp: strings.TrimSpace(` -*** ALPHA; all of this is subject to change *** +*** BETA; all of this is subject to change *** The 'tailscale serve' set of commands allows you to serve content and local servers from your Tailscale node to -your tailnet. +your tailnet. You can also choose to enable the Tailscale Funnel with: -'tailscale serve funnel on'. Funnel allows you to publish +'tailscale funnel on'. Funnel allows you to publish a 'tailscale serve' server publicly, open to the entire internet. See https://tailscale.com/funnel. EXAMPLES - To proxy requests to a web server at 127.0.0.1:3000: - $ tailscale serve / proxy 3000 + $ tailscale serve https:443 / http://127.0.0.1:3000 + + Or, using the default port: + $ tailscale serve https / http://127.0.0.1:3000 - To serve a single file or a directory of files: - $ tailscale serve / path /home/alice/blog/index.html - $ tailscale serve /images/ path /home/alice/blog/images + $ tailscale serve https / /home/alice/blog/index.html + $ tailscale serve https /images/ /home/alice/blog/images - To serve simple static text: - $ tailscale serve / text "Hello, world!" + $ tailscale serve https:8080 / text:"Hello, world!" + + - To forward incoming TCP connections on port 2222 to a local TCP server on + port 22 (e.g. to run OpenSSH in parallel with Tailscale SSH): + $ tailscale serve tcp:2222 tcp://localhost:22 + + - To accept TCP TLS connections (terminated within tailscaled) proxied to a + local plaintext server on port 80: + $ tailscale serve tls-terminated-tcp:443 tcp://localhost:80 `), - Exec: e.runServe, - FlagSet: e.newFlags("serve", func(fs *flag.FlagSet) { - fs.BoolVar(&e.remove, "remove", false, "remove an existing serve config") - fs.UintVar(&e.servePort, "serve-port", 443, "port to serve on (443, 8443 or 10000)") - }), + Exec: e.runServe, UsageFunc: usageFunc, Subcommands: []*ffcli.Command{ { Name: "status", Exec: e.runServeStatus, - ShortHelp: "show current serve status", + ShortHelp: "show current serve/funnel status", FlagSet: e.newFlags("serve-status", func(fs *flag.FlagSet) { fs.BoolVar(&e.json, "json", false, "output JSON") }), UsageFunc: usageFunc, }, - { - Name: "tcp", - Exec: e.runServeTCP, - ShortHelp: "add or remove a TCP port forward", - LongHelp: strings.Join([]string{ - "EXAMPLES", - " - Forward TLS over TCP to a local TCP server on port 5432:", - " $ tailscale serve tcp 5432", - "", - " - Forward raw, TLS-terminated TCP packets to a local TCP server on port 5432:", - " $ tailscale serve tcp --terminate-tls 5432", - }, "\n"), - FlagSet: e.newFlags("serve-tcp", func(fs *flag.FlagSet) { - fs.BoolVar(&e.terminateTLS, "terminate-tls", false, "terminate TLS before forwarding TCP connection") - }), - UsageFunc: usageFunc, - }, - { - Name: "funnel", - Exec: e.runServeFunnel, - ShortUsage: "funnel [flags] {on|off}", - ShortHelp: "turn Tailscale Funnel on or off", - LongHelp: strings.Join([]string{ - "Funnel allows you to publish a 'tailscale serve'", - "server publicly, open to the entire internet.", - "", - "Turning off Funnel only turns off serving to the internet.", - "It does not affect serving to your tailnet.", - }, "\n"), - UsageFunc: usageFunc, - }, }, } } @@ -145,10 +122,7 @@ type localServeClient interface { // It also contains the flags, as registered with newServeCommand. type serveEnv struct { // flags - servePort uint // Port to serve on. Defaults to 443. - terminateTLS bool - remove bool // remove a serve config - json bool // output JSON (status only for now) + json bool // output JSON (status only for now) lc localServeClient // localClient interface, specific to serve @@ -188,28 +162,15 @@ func (e *serveEnv) getLocalClientStatus(ctx context.Context) (*ipnstate.Status, return st, nil } -// validateServePort returns --serve-port flag value, -// or an error if the port is not a valid port to serve on. -func (e *serveEnv) validateServePort() (port uint16, err error) { - // make sure e.servePort is uint16 - port = uint16(e.servePort) - if uint(port) != e.servePort { - return 0, fmt.Errorf("serve-port %d is out of range", e.servePort) - } - // make sure e.servePort is 443, 8443 or 10000 - if port != 443 && port != 8443 && port != 10000 { - return 0, fmt.Errorf("serve-port %d is invalid; must be 443, 8443 or 10000", e.servePort) - } - return port, nil -} - // runServe is the entry point for the "serve" subcommand, managing Web // serve config types like proxy, path, and text. // // Examples: -// - tailscale serve / proxy 3000 -// - tailscale serve /images/ path /var/www/images/ -// - tailscale --serve-port=10000 serve /motd.txt text "Hello, world!" +// - tailscale serve https / http://localhost:3000 +// - tailscale serve https /images/ /var/www/images/ +// - tailscale serve https:10000 /motd.txt text:"Hello, world!" +// - tailscale serve tcp:2222 tcp://localhost:22 +// - tailscale serve tls-terminated-tcp:443 tcp://localhost:80 func (e *serveEnv) runServe(ctx context.Context, args []string) error { if len(args) == 0 { return flag.ErrHelp @@ -229,39 +190,94 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error { return e.lc.SetServeConfig(ctx, sc) } - if !(len(args) == 3 || (e.remove && len(args) >= 1)) { - fmt.Fprintf(os.Stderr, "error: invalid number of arguments\n\n") - return flag.ErrHelp + parsePort := func(portStr string) (uint16, error) { + port64, err := strconv.ParseUint(portStr, 10, 16) + if err != nil { + return 0, err + } + return uint16(port64), nil } - srvPort, err := e.validateServePort() - if err != nil { - return err + srcType, srcPortStr, found := strings.Cut(args[0], ":") + if !found { + if srcType == "https" && srcPortStr == "" { + // Default https port to 443. + srcPortStr = "443" + } else { + return flag.ErrHelp + } } - srvPortStr := strconv.Itoa(int(srvPort)) - mount, err := cleanMountPoint(args[0]) + turnOff := "off" == args[len(args)-1] + + if len(args) < 2 || (srcType == "https" && !turnOff && len(args) < 3) { + fmt.Fprintf(os.Stderr, "error: invalid number of arguments\n\n") + return flag.ErrHelp + } + + srcPort, err := parsePort(srcPortStr) if err != nil { return err } - if e.remove { - return e.handleWebServeRemove(ctx, mount) + switch srcType { + case "https": + mount, err := cleanMountPoint(args[1]) + if err != nil { + return err + } + if turnOff { + return e.handleWebServeRemove(ctx, srcPort, mount) + } + return e.handleWebServe(ctx, srcPort, mount, args[2]) + case "tcp", "tls-terminated-tcp": + if turnOff { + return e.handleTCPServeRemove(ctx, srcPort) + } + return e.handleTCPServe(ctx, srcType, srcPort, args[1]) + default: + fmt.Fprintf(os.Stderr, "error: invalid serve type %q\n", srcType) + fmt.Fprint(os.Stderr, "must be one of: https:, tcp: or tls-terminated-tcp:\n\n", srcType) + return flag.ErrHelp } +} +// handleWebServe handles the "tailscale serve https:..." subcommand. +// It configures the serve config to forward HTTPS connections to the +// given source. +// +// Examples: +// - tailscale serve https / http://localhost:3000 +// - tailscale serve https:8443 /files/ /home/alice/shared-files/ +// - tailscale serve https:10000 /motd.txt text:"Hello, world!" +func (e *serveEnv) handleWebServe(ctx context.Context, srvPort uint16, mount, source string) error { h := new(ipn.HTTPHandler) - switch args[1] { - case "path": + ts, _, _ := strings.Cut(source, ":") + switch { + case ts == "text": + text := strings.TrimPrefix(source, "text:") + if text == "" { + return errors.New("unable to serve; text cannot be an empty string") + } + h.Text = text + case isProxyTarget(source): + t, err := expandProxyTarget(source) + if err != nil { + return err + } + h.Proxy = t + default: // assume path if version.IsSandboxedMacOS() { // don't allow path serving for now on macOS (2022-11-15) return fmt.Errorf("path serving is not supported if sandboxed on macOS") } - if !filepath.IsAbs(args[2]) { + if !filepath.IsAbs(source) { fmt.Fprintf(os.Stderr, "error: path must be absolute\n\n") return flag.ErrHelp } - fi, err := os.Stat(args[2]) + source = filepath.Clean(source) + fi, err := os.Stat(source) if err != nil { fmt.Fprintf(os.Stderr, "error: invalid path: %v\n\n", err) return flag.ErrHelp @@ -271,21 +287,7 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error { // for relative file links to work mount += "/" } - h.Path = args[2] - case "proxy": - t, err := expandProxyTarget(args[2]) - if err != nil { - return err - } - h.Proxy = t - case "text": - if args[2] == "" { - return errors.New("unable to serve; text cannot be an empty string") - } - h.Text = args[2] - default: - fmt.Fprintf(os.Stderr, "error: unknown serve type %q\n\n", args[1]) - return flag.ErrHelp + h.Path = source } cursc, err := e.lc.GetServeConfig(ctx) @@ -300,7 +302,7 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error { if err != nil { return err } - hp := ipn.HostPort(net.JoinHostPort(dnsName, srvPortStr)) + hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort)))) if sc.IsTCPForwardingOnPort(srvPort) { fmt.Fprintf(os.Stderr, "error: cannot serve web; already serving TCP\n") @@ -339,12 +341,36 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error { return nil } -func (e *serveEnv) handleWebServeRemove(ctx context.Context, mount string) error { - srvPort, err := e.validateServePort() - if err != nil { - return err +// isProxyTarget reports whether source is a valid proxy target. +func isProxyTarget(source string) bool { + if strings.HasPrefix(source, "http://") || + strings.HasPrefix(source, "https://") || + strings.HasPrefix(source, "https+insecure://") { + return true + } + // support "localhost:3000", for example + _, portStr, ok := strings.Cut(source, ":") + if ok && allNumeric(portStr) { + return true + } + return false +} + +// allNumeric reports whether s only comprises of digits +// and has at least one digit. +func allNumeric(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } } - srvPortStr := strconv.Itoa(int(srvPort)) + return s != "" +} + +// handleWebServeRemove removes a web handler from the serve config. +// The srvPort argument is the serving port and the mount argument is +// the mount point or registered path to remove. +func (e *serveEnv) handleWebServeRemove(ctx context.Context, srvPort uint16, mount string) error { sc, err := e.lc.GetServeConfig(ctx) if err != nil { return err @@ -359,9 +385,9 @@ func (e *serveEnv) handleWebServeRemove(ctx context.Context, mount string) error if sc.IsTCPForwardingOnPort(srvPort) { return errors.New("cannot remove web handler; currently serving TCP") } - hp := ipn.HostPort(net.JoinHostPort(dnsName, srvPortStr)) + hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort)))) if !sc.WebHandlerExists(hp, mount) { - return errors.New("error: serve config does not exist") + return errors.New("error: handler does not exist") } // delete existing handler, then cascade delete if empty delete(sc.Web[hp].Handlers, mount) @@ -396,18 +422,11 @@ func cleanMountPoint(mount string) (string, error) { return "", fmt.Errorf("invalid mount point %q", mount) } -func expandProxyTarget(target string) (string, error) { - if allNumeric(target) { - p, err := strconv.ParseUint(target, 10, 16) - if p == 0 || err != nil { - return "", fmt.Errorf("invalid port %q", target) - } - return "http://127.0.0.1:" + target, nil +func expandProxyTarget(source string) (string, error) { + if !strings.Contains(source, "://") { + source = "http://" + source } - if !strings.Contains(target, "://") { - target = "http://" + target - } - u, err := url.ParseRequestURI(target) + u, err := url.ParseRequestURI(source) if err != nil { return "", fmt.Errorf("parsing url: %w", err) } @@ -417,9 +436,14 @@ func expandProxyTarget(target string) (string, error) { default: return "", fmt.Errorf("must be a URL starting with http://, https://, or https+insecure://") } + + port, err := strconv.ParseUint(u.Port(), 10, 16) + if port == 0 || err != nil { + return "", fmt.Errorf("invalid port %q: %w", u.Port(), err) + } + host := u.Hostname() switch host { - // TODO(shayne,bradfitz): do we want to do this? case "localhost", "127.0.0.1": host = "127.0.0.1" default: @@ -429,19 +453,115 @@ func expandProxyTarget(target string) (string, error) { if u.Port() != "" { url += ":" + u.Port() } + url += u.Path return url, nil } -func allNumeric(s string) bool { - for i := 0; i < len(s); i++ { - if s[i] < '0' || s[i] > '9' { - return false +// handleTCPServe handles the "tailscale serve tls-terminated-tcp:..." subcommand. +// It configures the serve config to forward TCP connections to the +// given source. +// +// Examples: +// - tailscale serve tcp:2222 tcp://localhost:22 +// - tailscale serve tls-terminated-tcp:8443 tcp://localhost:8080 +func (e *serveEnv) handleTCPServe(ctx context.Context, srcType string, srcPort uint16, dest string) error { + var terminateTLS bool + switch srcType { + case "tcp": + terminateTLS = false + case "tls-terminated-tcp": + terminateTLS = true + default: + fmt.Fprintf(os.Stderr, "error: invalid TCP source %q\n\n", dest) + return flag.ErrHelp + } + + dstURL, err := url.Parse(dest) + if err != nil { + fmt.Fprintf(os.Stderr, "error: invalid TCP source %q: %v\n\n", dest, err) + return flag.ErrHelp + } + host, dstPortStr, err := net.SplitHostPort(dstURL.Host) + if err != nil { + fmt.Fprintf(os.Stderr, "error: invalid TCP source %q: %v\n\n", dest, err) + return flag.ErrHelp + } + + switch host { + case "localhost", "127.0.0.1": + // ok + default: + fmt.Fprintf(os.Stderr, "error: invalid TCP source %q\n", dest) + fmt.Fprint(os.Stderr, "must be one of: localhost or 127.0.0.1\n\n", dest) + return flag.ErrHelp + } + + if p, err := strconv.ParseUint(dstPortStr, 10, 16); p == 0 || err != nil { + fmt.Fprintf(os.Stderr, "error: invalid port %q\n\n", dstPortStr) + return flag.ErrHelp + } + + cursc, err := e.lc.GetServeConfig(ctx) + if err != nil { + return err + } + sc := cursc.Clone() // nil if no config + if sc == nil { + sc = new(ipn.ServeConfig) + } + + fwdAddr := "127.0.0.1:" + dstPortStr + + if sc.IsServingWeb(srcPort) { + return fmt.Errorf("cannot serve TCP; already serving web on %d", srcPort) + } + + mak.Set(&sc.TCP, srcPort, &ipn.TCPPortHandler{TCPForward: fwdAddr}) + + dnsName, err := e.getSelfDNSName(ctx) + if err != nil { + return err + } + if terminateTLS { + sc.TCP[srcPort].TerminateTLS = dnsName + } + + if !reflect.DeepEqual(cursc, sc) { + if err := e.lc.SetServeConfig(ctx, sc); err != nil { + return err } } - return s != "" + + return nil } -// runServeStatus prints the current serve config. +// handleTCPServeRemove removes the TCP forwarding configuration for the +// given srvPort, or serving port. +func (e *serveEnv) handleTCPServeRemove(ctx context.Context, src uint16) error { + cursc, err := e.lc.GetServeConfig(ctx) + if err != nil { + return err + } + sc := cursc.Clone() // nil if no config + if sc == nil { + sc = new(ipn.ServeConfig) + } + if sc.IsServingWeb(src) { + return fmt.Errorf("unable to remove; serving web, not TCP forwarding on serve port %d", src) + } + if ph := sc.GetTCPPortHandler(src); ph != nil { + delete(sc.TCP, src) + // clear map mostly for testing + if len(sc.TCP) == 0 { + sc.TCP = nil + } + return e.lc.SetServeConfig(ctx, sc) + } + return errors.New("error: serve config does not exist") +} + +// runServeStatus is the entry point for the "serve status" +// subcommand and prints the current serve config. // // Examples: // - tailscale status @@ -460,6 +580,7 @@ func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error { e.stdout().Write(j) return nil } + printFunnelStatus(ctx) if sc == nil || (len(sc.TCP) == 0 && len(sc.Web) == 0 && len(sc.AllowFunnel) == 0) { printf("No serve config\n") return nil @@ -478,17 +599,7 @@ func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error { printWebStatusTree(sc, hp) printf("\n") } - // warn when funnel on without handlers - for hp, a := range sc.AllowFunnel { - if !a { - continue - } - _, portStr, _ := net.SplitHostPort(string(hp)) - p, _ := strconv.ParseUint(portStr, 10, 16) - if _, ok := sc.TCP[uint16(p)]; !ok { - printf("WARNING: funnel=on for %s, but no serve config\n", hp) - } - } + printFunnelWarning(sc) return nil } @@ -572,152 +683,3 @@ func elipticallyTruncate(s string, max int) string { } return s[:max-3] + "..." } - -// runServeTCP is the entry point for the "serve tcp" subcommand and -// manages the serve config for TCP forwarding. -// -// Examples: -// - tailscale serve tcp 5432 -// - tailscale serve --serve-port=8443 tcp 4430 -// - tailscale serve --serve-port=10000 tcp --terminate-tls 8080 -func (e *serveEnv) runServeTCP(ctx context.Context, args []string) error { - if len(args) != 1 { - fmt.Fprintf(os.Stderr, "error: invalid number of arguments\n\n") - return flag.ErrHelp - } - - srvPort, err := e.validateServePort() - if err != nil { - return err - } - - portStr := args[0] - p, err := strconv.ParseUint(portStr, 10, 16) - if p == 0 || err != nil { - fmt.Fprintf(os.Stderr, "error: invalid port %q\n\n", portStr) - } - - cursc, err := e.lc.GetServeConfig(ctx) - if err != nil { - return err - } - sc := cursc.Clone() // nil if no config - if sc == nil { - sc = new(ipn.ServeConfig) - } - - fwdAddr := "127.0.0.1:" + portStr - - if sc.IsServingWeb(srvPort) { - if e.remove { - return fmt.Errorf("unable to remove; serving web, not TCP forwarding on serve port %d", srvPort) - } - return fmt.Errorf("cannot serve TCP; already serving web on %d", srvPort) - } - - if e.remove { - if ph := sc.GetTCPPortHandler(srvPort); ph != nil && ph.TCPForward == fwdAddr { - delete(sc.TCP, srvPort) - // clear map mostly for testing - if len(sc.TCP) == 0 { - sc.TCP = nil - } - return e.lc.SetServeConfig(ctx, sc) - } - return errors.New("error: serve config does not exist") - } - - mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{TCPForward: fwdAddr}) - - dnsName, err := e.getSelfDNSName(ctx) - if err != nil { - return err - } - if e.terminateTLS { - sc.TCP[srvPort].TerminateTLS = dnsName - } - - if !reflect.DeepEqual(cursc, sc) { - if err := e.lc.SetServeConfig(ctx, sc); err != nil { - return err - } - } - - return nil -} - -// runServeFunnel is the entry point for the "serve funnel" subcommand and -// manages turning on/off funnel. Funnel is off by default. -// -// Note: funnel is only supported on single DNS name for now. (2022-11-15) -func (e *serveEnv) runServeFunnel(ctx context.Context, args []string) error { - if len(args) != 1 { - return flag.ErrHelp - } - - srvPort, err := e.validateServePort() - if err != nil { - return err - } - srvPortStr := strconv.Itoa(int(srvPort)) - - var on bool - switch args[0] { - case "on", "off": - on = args[0] == "on" - default: - return flag.ErrHelp - } - sc, err := e.lc.GetServeConfig(ctx) - if err != nil { - return err - } - if sc == nil { - sc = new(ipn.ServeConfig) - } - st, err := e.getLocalClientStatus(ctx) - if err != nil { - return fmt.Errorf("getting client status: %w", err) - } - if err := checkHasAccess(st.Self.Capabilities); err != nil { - return err - } - dnsName := strings.TrimSuffix(st.Self.DNSName, ".") - hp := ipn.HostPort(dnsName + ":" + srvPortStr) - if on == sc.AllowFunnel[hp] { - // Nothing to do. - return nil - } - if on { - mak.Set(&sc.AllowFunnel, hp, true) - } else { - delete(sc.AllowFunnel, hp) - // clear map mostly for testing - if len(sc.AllowFunnel) == 0 { - sc.AllowFunnel = nil - } - } - if err := e.lc.SetServeConfig(ctx, sc); err != nil { - return err - } - return nil -} - -// checkHasAccess checks three things: 1) an invite was used to join the -// Funnel alpha; 2) HTTPS is enabled; 3) the node has the "funnel" attribute. -// If any of these are false, an error is returned describing the problem. -// -// The nodeAttrs arg should be the node's Self.Capabilities which should contain -// the attribute we're checking for and possibly warning-capabilities for Funnel. -func checkHasAccess(nodeAttrs []string) error { - if slices.Contains(nodeAttrs, tailcfg.CapabilityWarnFunnelNoInvite) { - return errors.New("Funnel not available; an invite is required to join the alpha. See https://tailscale.com/kb/1223/tailscale-funnel/.") - } - if slices.Contains(nodeAttrs, tailcfg.CapabilityWarnFunnelNoHTTPS) { - return errors.New("Funnel not available; HTTPS must be enabled. See https://tailscale.com/kb/1153/enabling-https/.") - } - if !slices.Contains(nodeAttrs, tailcfg.NodeAttrFunnel) { - return errors.New("Funnel not available; \"funnel\" node attribute not set. See https://tailscale.com/kb/1223/tailscale-funnel/.") - } - return nil -} diff --git a/cmd/tailscale/cli/serve_test.go b/cmd/tailscale/cli/serve_test.go index 13795bde9bdf1..8031b2b024f92 100644 --- a/cmd/tailscale/cli/serve_test.go +++ b/cmd/tailscale/cli/serve_test.go @@ -15,6 +15,7 @@ import ( "strings" "testing" + "github.com/peterbourgon/ff/v3/ffcli" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" @@ -48,30 +49,6 @@ func TestCleanMountPoint(t *testing.T) { } } -func TestCheckHasAccess(t *testing.T) { - tests := []struct { - caps []string - wantErr bool - }{ - {[]string{}, true}, // No "funnel" attribute - {[]string{tailcfg.CapabilityWarnFunnelNoInvite}, true}, - {[]string{tailcfg.CapabilityWarnFunnelNoHTTPS}, true}, - {[]string{tailcfg.NodeAttrFunnel}, false}, - } - for _, tt := range tests { - err := checkHasAccess(tt.caps) - switch { - case err != nil && tt.wantErr, - err == nil && !tt.wantErr: - continue - case tt.wantErr: - t.Fatalf("got no error, want error") - case !tt.wantErr: - t.Fatalf("got error %v, want no error", err) - } - } -} - func TestServeConfigMutations(t *testing.T) { // Stateful mutations, starting from an empty config. type step struct { @@ -80,6 +57,8 @@ func TestServeConfigMutations(t *testing.T) { want *ipn.ServeConfig // non-nil means we want a save of this value wantErr func(error) (badErrMsg string) // nil means no error is wanted line int // line number of addStep call, for error messages + + debugBreak func() } var steps []step add := func(s step) { @@ -90,19 +69,19 @@ func TestServeConfigMutations(t *testing.T) { // funnel add(step{reset: true}) add(step{ - command: cmd("funnel on"), + command: cmd("funnel 443 on"), want: &ipn.ServeConfig{AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true}}, }) add(step{ - command: cmd("funnel on"), + command: cmd("funnel 443 on"), want: nil, // nothing to save }) add(step{ - command: cmd("funnel off"), + command: cmd("funnel 443 off"), want: &ipn.ServeConfig{}, }) add(step{ - command: cmd("funnel off"), + command: cmd("funnel 443 off"), want: nil, // nothing to save }) add(step{ @@ -113,27 +92,48 @@ func TestServeConfigMutations(t *testing.T) { // https add(step{reset: true}) add(step{ - command: cmd("/ proxy 0"), // invalid port, too low + command: cmd("https:443 / http://localhost:0"), // invalid port, too low wantErr: anyErr(), }) add(step{ - command: cmd("/ proxy 65536"), // invalid port, too high + command: cmd("https:443 / http://localhost:65536"), // invalid port, too high wantErr: anyErr(), }) add(step{ - command: cmd("/ proxy somehost"), // invalid host + command: cmd("https:443 / http://somehost:3000"), // invalid host wantErr: anyErr(), }) add(step{ - command: cmd("/ proxy http://otherhost"), // invalid host + command: cmd("https:443 / httpz://127.0.0.1"), // invalid scheme wantErr: anyErr(), }) - add(step{ - command: cmd("/ proxy httpz://127.0.0.1"), // invalid scheme - wantErr: anyErr(), + add(step{ // allow omitting port (default to 443) + command: cmd("https / http://localhost:3000"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://127.0.0.1:3000"}, + }}, + }, + }, + }) + add(step{ // support non Funnel port + command: cmd("https:9999 /abc http://localhost:3001"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 9999: {HTTPS: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://127.0.0.1:3000"}, + }}, + "foo.test.ts.net:9999": {Handlers: map[string]*ipn.HTTPHandler{ + "/abc": {Proxy: "http://127.0.0.1:3001"}, + }}, + }, + }, }) add(step{ - command: cmd("/ proxy 3000"), + command: cmd("https:9999 /abc off"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -143,12 +143,8 @@ func TestServeConfigMutations(t *testing.T) { }, }, }) - add(step{ // invalid port - command: cmd("--serve-port=9999 /abc proxy 3001"), - wantErr: anyErr(), - }) add(step{ - command: cmd("--serve-port=8443 /abc proxy 3001"), + command: cmd("https:8443 /abc http://127.0.0.1:3001"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -162,7 +158,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ - command: cmd("--serve-port=10000 / text hi"), + command: cmd("https:10000 / text:hi"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ 443: {HTTPS: true}, 8443: {HTTPS: true}, 10000: {HTTPS: true}}, @@ -180,12 +176,12 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ - command: cmd("--remove /foo"), + command: cmd("https:443 /foo off"), want: nil, // nothing to save wantErr: anyErr(), }) // handler doesn't exist, so we get an error add(step{ - command: cmd("--remove --serve-port=10000 /"), + command: cmd("https:10000 / off"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -199,7 +195,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ - command: cmd("--remove /"), + command: cmd("https:443 / off"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{8443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -210,11 +206,11 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ - command: cmd("--remove --serve-port=8443 /abc"), + command: cmd("https:8443 /abc off"), want: &ipn.ServeConfig{}, }) - add(step{ - command: cmd("bar proxy https://127.0.0.1:8443"), + add(step{ // clean mount: "bar" becomes "/bar" + command: cmd("https:443 bar https://127.0.0.1:8443"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -225,12 +221,12 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ - command: cmd("bar proxy https://127.0.0.1:8443"), + command: cmd("https:443 bar https://127.0.0.1:8443"), want: nil, // nothing to save }) add(step{reset: true}) add(step{ - command: cmd("/ proxy https+insecure://127.0.0.1:3001"), + command: cmd("https:443 / https+insecure://127.0.0.1:3001"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -242,7 +238,7 @@ func TestServeConfigMutations(t *testing.T) { }) add(step{reset: true}) add(step{ - command: cmd("/foo proxy localhost:3000"), + command: cmd("https:443 /foo localhost:3000"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -253,7 +249,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ // test a second handler on the same port - command: cmd("--serve-port=8443 /foo proxy localhost:3000"), + command: cmd("https:8443 /foo localhost:3000"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -266,19 +262,50 @@ func TestServeConfigMutations(t *testing.T) { }, }, }) + add(step{reset: true}) + add(step{ // support path in proxy + command: cmd("https / http://127.0.0.1:3000/foo/bar"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://127.0.0.1:3000/foo/bar"}, + }}, + }, + }, + }) // tcp add(step{reset: true}) + add(step{ // must include scheme for tcp + command: cmd("tls-terminated-tcp:443 localhost:5432"), + wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"), + }) + add(step{ // !somehost, must be localhost or 127.0.0.1 + command: cmd("tls-terminated-tcp:443 tcp://somehost:5432"), + wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"), + }) + add(step{ // bad target port, too low + command: cmd("tls-terminated-tcp:443 tcp://somehost:0"), + wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"), + }) + add(step{ // bad target port, too high + command: cmd("tls-terminated-tcp:443 tcp://somehost:65536"), + wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"), + }) add(step{ - command: cmd("tcp 5432"), + command: cmd("tls-terminated-tcp:443 tcp://localhost:5432"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ - 443: {TCPForward: "127.0.0.1:5432"}, + 443: { + TCPForward: "127.0.0.1:5432", + TerminateTLS: "foo.test.ts.net", + }, }, }, }) add(step{ - command: cmd("tcp -terminate-tls 8443"), + command: cmd("tls-terminated-tcp:443 tcp://127.0.0.1:8443"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ 443: { @@ -289,11 +316,11 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ - command: cmd("tcp -terminate-tls 8443"), + command: cmd("tls-terminated-tcp:443 tcp://127.0.0.1:8443"), want: nil, // nothing to save }) add(step{ - command: cmd("tcp --terminate-tls 8444"), + command: cmd("tls-terminated-tcp:443 tcp://localhost:8444"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ 443: { @@ -304,35 +331,41 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ - command: cmd("tcp -terminate-tls=false 8445"), + command: cmd("tls-terminated-tcp:443 tcp://127.0.0.1:8445"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ - 443: {TCPForward: "127.0.0.1:8445"}, + 443: { + TCPForward: "127.0.0.1:8445", + TerminateTLS: "foo.test.ts.net", + }, }, }, }) add(step{reset: true}) add(step{ - command: cmd("tcp 123"), + command: cmd("tls-terminated-tcp:443 tcp://localhost:123"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ - 443: {TCPForward: "127.0.0.1:123"}, + 443: { + TCPForward: "127.0.0.1:123", + TerminateTLS: "foo.test.ts.net", + }, }, }, }) - add(step{ - command: cmd("--remove tcp 321"), + add(step{ // handler doesn't exist, so we get an error + command: cmd("tls-terminated-tcp:8443 off"), wantErr: anyErr(), - }) // handler doesn't exist, so we get an error + }) add(step{ - command: cmd("--remove tcp 123"), + command: cmd("tls-terminated-tcp:443 off"), want: &ipn.ServeConfig{}, }) // text add(step{reset: true}) add(step{ - command: cmd("/ text hello"), + command: cmd("https:443 / text:hello"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -353,7 +386,7 @@ func TestServeConfigMutations(t *testing.T) { add(step{reset: true}) writeFile("foo", "this is foo") add(step{ - command: cmd("/ path " + filepath.Join(td, "foo")), + command: cmd("https:443 / " + filepath.Join(td, "foo")), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -366,7 +399,7 @@ func TestServeConfigMutations(t *testing.T) { os.MkdirAll(filepath.Join(td, "subdir"), 0700) writeFile("subdir/file-a", "this is A") add(step{ - command: cmd("/some/where path " + filepath.Join(td, "subdir/file-a")), + command: cmd("https:443 /some/where " + filepath.Join(td, "subdir/file-a")), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -377,13 +410,13 @@ func TestServeConfigMutations(t *testing.T) { }, }, }) - add(step{ - command: cmd("/ path missing"), + add(step{ // bad path + command: cmd("https:443 / bad/path"), wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"), }) add(step{reset: true}) add(step{ - command: cmd("/ path " + filepath.Join(td, "subdir")), + command: cmd("https:443 / " + filepath.Join(td, "subdir")), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -394,14 +427,14 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ - command: cmd("--remove /"), + command: cmd("https:443 / off"), want: &ipn.ServeConfig{}, }) // combos add(step{reset: true}) add(step{ - command: cmd("/ proxy 3000"), + command: cmd("https:443 / localhost:3000"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -412,7 +445,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ - command: cmd("funnel on"), + command: cmd("funnel 443 on"), want: &ipn.ServeConfig{ AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true}, TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, @@ -424,7 +457,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ // serving on secondary port doesn't change funnel - command: cmd("--serve-port=8443 /bar proxy 3001"), + command: cmd("https:8443 /bar localhost:3001"), want: &ipn.ServeConfig{ AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true}, TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}}, @@ -439,7 +472,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ // turn funnel on for secondary port - command: cmd("--serve-port=8443 funnel on"), + command: cmd("funnel 8443 on"), want: &ipn.ServeConfig{ AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true, "foo.test.ts.net:8443": true}, TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}}, @@ -454,7 +487,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ // turn funnel off for primary port 443 - command: cmd("funnel off"), + command: cmd("funnel 443 off"), want: &ipn.ServeConfig{ AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true}, TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}}, @@ -469,7 +502,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ // remove secondary port - command: cmd("--serve-port=8443 --remove /bar"), + command: cmd("https:8443 /bar off"), want: &ipn.ServeConfig{ AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true}, TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, @@ -481,7 +514,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ // start a tcp forwarder on 8443 - command: cmd("--serve-port=8443 tcp 5432"), + command: cmd("tcp:8443 tcp://localhost:5432"), want: &ipn.ServeConfig{ AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true}, TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {TCPForward: "127.0.0.1:5432"}}, @@ -493,27 +526,27 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ // remove primary port http handler - command: cmd("--remove /"), + command: cmd("https:443 / off"), want: &ipn.ServeConfig{ AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true}, TCP: map[uint16]*ipn.TCPPortHandler{8443: {TCPForward: "127.0.0.1:5432"}}, }, }) add(step{ // remove tcp forwarder - command: cmd("--serve-port=8443 --remove tcp 5432"), + command: cmd("tls-terminated-tcp:8443 off"), want: &ipn.ServeConfig{ AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true}, }, }) add(step{ // turn off funnel - command: cmd("--serve-port=8443 funnel off"), + command: cmd("funnel 8443 off"), want: &ipn.ServeConfig{}, }) // tricky steps add(step{reset: true}) add(step{ // a directory with a trailing slash mount point - command: cmd("/dir path " + filepath.Join(td, "subdir")), + command: cmd("https:443 /dir " + filepath.Join(td, "subdir")), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -524,7 +557,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ // this should overwrite the previous one - command: cmd("/dir path " + filepath.Join(td, "foo")), + command: cmd("https:443 /dir " + filepath.Join(td, "foo")), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -536,7 +569,7 @@ func TestServeConfigMutations(t *testing.T) { }) add(step{reset: true}) // reset and do the opposite add(step{ // a file without a trailing slash mount point - command: cmd("/dir path " + filepath.Join(td, "foo")), + command: cmd("https:443 /dir " + filepath.Join(td, "foo")), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -547,7 +580,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ // this should overwrite the previous one - command: cmd("/dir path " + filepath.Join(td, "subdir")), + command: cmd("https:443 /dir " + filepath.Join(td, "subdir")), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -560,37 +593,24 @@ func TestServeConfigMutations(t *testing.T) { // error states add(step{reset: true}) - add(step{ // make sure we can't add "tcp" as if it was a mount - command: cmd("tcp text foo"), - wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"), - }) - add(step{ // "/tcp" is fine though as a mount - command: cmd("/tcp text foo"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/tcp": {Text: "foo"}, - }}, - }, - }, - }) - add(step{reset: true}) add(step{ // tcp forward 5432 on serve port 443 - command: cmd("tcp 5432"), + command: cmd("tls-terminated-tcp:443 tcp://localhost:5432"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ - 443: {TCPForward: "127.0.0.1:5432"}, + 443: { + TCPForward: "127.0.0.1:5432", + TerminateTLS: "foo.test.ts.net", + }, }, }, }) add(step{ // try to start a web handler on the same port - command: cmd("/ proxy 3000"), + command: cmd("https:443 / localhost:3000"), wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"), }) add(step{reset: true}) add(step{ // start a web handler on port 443 - command: cmd("/ proxy 3000"), + command: cmd("https:443 / localhost:3000"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -600,14 +620,17 @@ func TestServeConfigMutations(t *testing.T) { }, }, }) - add(step{ // try to start a tcp forwarder on the same serve port (443 default) - command: cmd("tcp 5432"), + add(step{ // try to start a tcp forwarder on the same serve port + command: cmd("tls-terminated-tcp:443 tcp://localhost:5432"), wantErr: anyErr(), }) lc := &fakeLocalServeClient{} // And now run the steps above. for i, st := range steps { + if st.debugBreak != nil { + st.debugBreak() + } if st.reset { t.Logf("Executing step #%d, line %v: [reset]", i, st.line) lc.config = nil @@ -625,8 +648,16 @@ func TestServeConfigMutations(t *testing.T) { testStdout: &stdout, } lastCount := lc.setCount - cmd := newServeCommand(e) - err := cmd.ParseAndRun(context.Background(), st.command) + var cmd *ffcli.Command + var args []string + if st.command[0] == "funnel" { + cmd = newFunnelCommand(e) + args = st.command[1:] + } else { + cmd = newServeCommand(e) + args = st.command + } + err := cmd.ParseAndRun(context.Background(), args) if flagOut.Len() > 0 { t.Logf("flag package output: %q", flagOut.Bytes()) } @@ -677,7 +708,7 @@ var fakeStatus = &ipnstate.Status{ BackendState: ipn.Running.String(), Self: &ipnstate.PeerStatus{ DNSName: "foo.test.ts.net", - Capabilities: []string{tailcfg.NodeAttrFunnel}, + Capabilities: []string{tailcfg.NodeAttrFunnel, tailcfg.CapabilityFunnelPorts + "?ports=443,8443"}, }, } @@ -717,7 +748,5 @@ func anyErr() func(error) string { } func cmd(s string) []string { - cmds := strings.Fields(s) - fmt.Printf("cmd: %v", cmds) - return cmds + return strings.Fields(s) } diff --git a/cmd/tailscale/cli/status.go b/cmd/tailscale/cli/status.go index 87eb6151e286d..53fb9997524fe 100644 --- a/cmd/tailscale/cli/status.go +++ b/cmd/tailscale/cli/status.go @@ -258,6 +258,7 @@ func printFunnelStatus(ctx context.Context) { } printf("# - %s\n", url) } + outln() } // isRunningOrStarting reports whether st is in state Running or Starting. @@ -275,7 +276,7 @@ func isRunningOrStarting(st *ipnstate.Status) (description string, ok bool) { } return s, false case ipn.NeedsMachineAuth.String(): - return "Machine is not yet authorized by tailnet admin.", false + return "Machine is not yet approved by tailnet admin.", false case ipn.Running.String(), ipn.Starting.String(): return st.BackendState, true } diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go index bd1d04c8167e6..92241748b8b25 100644 --- a/cmd/tailscale/cli/up.go +++ b/cmd/tailscale/cli/up.go @@ -34,6 +34,7 @@ import ( "tailscale.com/tailcfg" "tailscale.com/types/logger" "tailscale.com/types/preftype" + "tailscale.com/util/dnsname" "tailscale.com/version" "tailscale.com/version/distro" ) @@ -320,8 +321,8 @@ func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goo } } - if len(upArgs.hostname) > 256 { - return nil, fmt.Errorf("hostname too long: %d bytes (max 256)", len(upArgs.hostname)) + if err := dnsname.ValidHostname(upArgs.hostname); upArgs.hostname != "" && err != nil { + return nil, err } prefs := ipn.NewPrefs() @@ -409,6 +410,12 @@ func updatePrefs(prefs, curPrefs *ipn.Prefs, env upCheckEnv) (simpleUp bool, jus return false, nil, err } + if env.upArgs.forceReauth && isSSHOverTailscale() { + if err := presentRiskToUser(riskLoseSSH, `You are connected over Tailscale; this action will result in your SSH session disconnecting.`, env.upArgs.acceptedRisks); err != nil { + return false, nil, err + } + } + tagsChanged := !reflect.DeepEqual(curPrefs.AdvertiseTags, prefs.AdvertiseTags) simpleUp = env.flagSet.NFlag() == 0 && @@ -584,7 +591,7 @@ func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retE if env.upArgs.json { printUpDoneJSON(ipn.NeedsMachineAuth, "") } else { - fmt.Fprintf(Stderr, "\nTo authorize your machine, visit (as admin):\n\n\t%s\n\n", prefs.AdminPageURL()) + fmt.Fprintf(Stderr, "\nTo approve your machine, visit (as admin):\n\n\t%s\n\n", prefs.AdminPageURL()) } case ipn.Running: // Done full authentication process diff --git a/cmd/tailscale/cli/update.go b/cmd/tailscale/cli/update.go index 8f332b922f2e5..e295314d7a016 100644 --- a/cmd/tailscale/cli/update.go +++ b/cmd/tailscale/cli/update.go @@ -145,11 +145,11 @@ func newUpdater() (*updater, error) { case strings.HasSuffix(os.Getenv("HOME"), "/io.tailscale.ipn.macsys/Data"): up.update = up.updateMacSys default: - return nil, errors.New("This is the macOS App Store version of Tailscale; update in the App Store, or see https://tailscale.com/kb/1083/install-unstable/ to use TestFlight or to install the non-App Store version") + return nil, errors.New("This is the macOS App Store version of Tailscale; update in the App Store, or see https://tailscale.com/s/unstable-clients to use TestFlight or to install the non-App Store version") } } if up.update == nil { - return nil, errors.New("The 'update' command is not supported on this platform; see https://tailscale.com/kb/1067/update/") + return nil, errors.New("The 'update' command is not supported on this platform; see https://tailscale.com/s/client-updates") } return up, nil } @@ -160,12 +160,12 @@ type updater struct { } func (up *updater) currentOrDryRun(ver string) bool { - if version.Short == ver { + if version.Short() == ver { fmt.Printf("already running %v; no update needed\n", ver) return true } if updateArgs.dryRun { - fmt.Printf("Current: %v, Latest: %v\n", version.Short, ver) + fmt.Printf("Current: %v, Latest: %v\n", version.Short(), ver) return true } return false @@ -173,11 +173,11 @@ func (up *updater) currentOrDryRun(ver string) bool { func (up *updater) confirm(ver string) error { if updateArgs.yes { - log.Printf("Updating Tailscale from %v to %v; --yes given, continuing without prompts.\n", version.Short, ver) + log.Printf("Updating Tailscale from %v to %v; --yes given, continuing without prompts.\n", version.Short(), ver) return nil } - fmt.Printf("This will update Tailscale from %v to %v. Continue? [y/n] ", version.Short, ver) + fmt.Printf("This will update Tailscale from %v to %v. Continue? [y/n] ", version.Short(), ver) var resp string fmt.Scanln(&resp) resp = strings.ToLower(resp) @@ -430,7 +430,7 @@ func installMSI(msi string) error { if err == nil { break } - uninstallVersion := version.Short + uninstallVersion := version.Short() if v := os.Getenv("TS_DEBUG_UNINSTALL_VERSION"); v != "" { uninstallVersion = v } diff --git a/cmd/tailscale/cli/web.css b/cmd/tailscale/cli/web.css index fbe4ee11e4e42..5b9d9e0b6b0d7 100644 --- a/cmd/tailscale/cli/web.css +++ b/cmd/tailscale/cli/web.css @@ -1286,6 +1286,28 @@ html { color: rgba(25, 34, 74, var(--text-opacity)); } +.link-underline { + text-decoration: underline; +} + +.link-underline:hover, +.link-underline:active { + text-decoration: none; +} + +.link-muted { + /* same as text-gray-500 */ + --tw-text-opacity: 1; + color: rgba(112, 110, 109, var(--tw-text-opacity)); +} + +.link-muted:hover, +.link-muted:active { + /* same as text-gray-500 */ + --tw-text-opacity: 1; + color: rgba(68, 67, 66, var(--tw-text-opacity)); +} + .button { font-weight: 500; padding-top: 0.45rem; diff --git a/cmd/tailscale/cli/web.go b/cmd/tailscale/cli/web.go index 68e63ddf94b88..82105a6a3b415 100644 --- a/cmd/tailscale/cli/web.go +++ b/cmd/tailscale/cli/web.go @@ -60,6 +60,15 @@ type tmplData struct { LicensesURL string TUNMode bool IsSynology bool + DSMVersion int // 6 or 7, if IsSynology=true + IPNVersion string +} + +type postedData struct { + AdvertiseRoutes string + AdvertiseExitNode bool + Reauthenticate bool + ForceLogout bool } var webCmd = &ffcli.Command{ @@ -133,7 +142,7 @@ func runWeb(ctx context.Context, args []string) error { Handler: http.HandlerFunc(webHandler), } - log.Printf("web server runNIng on: https://%s", server.Addr) + log.Printf("web server running on: https://%s", server.Addr) return server.ListenAndServeTLS("", "") } else { log.Printf("web server running on: %s", urlOfListenAddr(webArgs.listen)) @@ -219,33 +228,48 @@ func qnapAuthn(r *http.Request) (string, *qnapAuthResponse, error) { return "", nil, fmt.Errorf("not authenticated by any mechanism") } -func qnapAuthnQtoken(r *http.Request, user, token string) (string, *qnapAuthResponse, error) { - query := url.Values{ - "qtoken": []string{token}, - "user": []string{user}, +// qnapAuthnURL returns the auth URL to use by inferring where the UI is +// running based on the request URL. This is necessary because QNAP has so +// many options, see https://github.com/tailscale/tailscale/issues/7108 +// and https://github.com/tailscale/tailscale/issues/6903 +func qnapAuthnURL(requestUrl string, query url.Values) string { + in, err := url.Parse(requestUrl) + scheme := "" + host := "" + if err != nil || in.Scheme == "" { + log.Printf("Cannot parse QNAP login URL %v", err) + + // try localhost and hope for the best + scheme = "http" + host = "localhost" + } else { + scheme = in.Scheme + host = in.Host } + u := url.URL{ - Scheme: "http", - Host: "127.0.0.1:8080", + Scheme: scheme, + Host: host, Path: "/cgi-bin/authLogin.cgi", RawQuery: query.Encode(), } - return qnapAuthnFinish(user, u.String()) + return u.String() +} + +func qnapAuthnQtoken(r *http.Request, user, token string) (string, *qnapAuthResponse, error) { + query := url.Values{ + "qtoken": []string{token}, + "user": []string{user}, + } + return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query)) } func qnapAuthnSid(r *http.Request, user, sid string) (string, *qnapAuthResponse, error) { query := url.Values{ "sid": []string{sid}, } - u := url.URL{ - Scheme: "http", - Host: "127.0.0.1:8080", - Path: "/cgi-bin/authLogin.cgi", - RawQuery: query.Encode(), - } - - return qnapAuthnFinish(user, u.String()) + return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query)) } func qnapAuthnFinish(user, url string) (string, *qnapAuthResponse, error) { @@ -353,11 +377,7 @@ func webHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" { defer r.Body.Close() - var postData struct { - AdvertiseRoutes string - AdvertiseExitNode bool - Reauthenticate bool - } + var postData postedData type mi map[string]any if err := json.NewDecoder(r.Body).Decode(&postData); err != nil { w.WriteHeader(400) @@ -386,8 +406,15 @@ func webHandler(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - log.Printf("tailscaleUp(reauth=%v) ...", postData.Reauthenticate) - url, err := tailscaleUp(r.Context(), st, postData.Reauthenticate) + var reauth, logout bool + if postData.Reauthenticate { + reauth = true + } + if postData.ForceLogout { + logout = true + } + log.Printf("tailscaleUp(reauth=%v, logout=%v) ...", reauth, logout) + url, err := tailscaleUp(r.Context(), st, postData) log.Printf("tailscaleUp = (URL %v, %v)", url != "", err) if err != nil { w.WriteHeader(http.StatusInternalServerError) @@ -404,6 +431,7 @@ func webHandler(w http.ResponseWriter, r *http.Request) { profile := st.User[st.Self.UserID] deviceName := strings.Split(st.Self.DNSName, ".")[0] + versionShort := strings.Split(st.Version, "-")[0] data := tmplData{ SynologyUser: user, Profile: profile, @@ -412,6 +440,8 @@ func webHandler(w http.ResponseWriter, r *http.Request) { LicensesURL: licensesURL(), TUNMode: st.TUN, IsSynology: distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"), + DSMVersion: distro.DSMVersion(), + IPNVersion: versionShort, } exitNodeRouteV4 := netip.MustParsePrefix("0.0.0.0/0") exitNodeRouteV6 := netip.MustParsePrefix("::/0") @@ -437,10 +467,18 @@ func webHandler(w http.ResponseWriter, r *http.Request) { w.Write(buf.Bytes()) } -func tailscaleUp(ctx context.Context, st *ipnstate.Status, forceReauth bool) (authURL string, retErr error) { +func tailscaleUp(ctx context.Context, st *ipnstate.Status, postData postedData) (authURL string, retErr error) { + if postData.ForceLogout { + if err := localClient.Logout(ctx); err != nil { + return "", fmt.Errorf("Logout error: %w", err) + } + return "", nil + } + origAuthURL := st.AuthURL isRunning := st.BackendState == ipn.Running.String() + forceReauth := postData.Reauthenticate if !forceReauth { if origAuthURL != "" { return origAuthURL, nil diff --git a/cmd/tailscale/cli/web.html b/cmd/tailscale/cli/web.html index 438877b292fc5..b1ad8746be3b4 100644 --- a/cmd/tailscale/cli/web.html +++ b/cmd/tailscale/cli/web.html @@ -26,10 +26,14 @@
- {{ with .Profile.LoginName }} -
-

{{.}}

- Switch account + {{ with .Profile }} +
+

{{.LoginName}}

+
{{ end }}
@@ -44,7 +48,7 @@

{{.}}

{{ if .IP }}
+ class="border border-gray-200 bg-gray-0 rounded-md p-2 pl-3 pr-3 width-full flex items-center justify-between">
{{.}} -

{{.DeviceName}}

+
+

{{.DeviceName}}

+
{{.IP}}
+

+ Debug info: Tailscale {{ .IPNVersion }}, tun={{.TUNMode}}{{ if .IsSynology }}, DSM{{ .DSMVersion}} + {{if not .TUNMode}} + (outgoing access not configured) + {{end}} + {{end}} +

{{ end }} {{ if or (eq .Status "NeedsLogin") (eq .Status "NoState") }} {{ if .IP }} @@ -95,18 +110,6 @@

Log in

{{end}}
- - {{ if .IsSynology }} -
- Outgoing access {{ if .TUNMode }}enabled{{ else }}not configured{{ end }}. - Learn more → -
- {{ end }} {{ end }}